Plugins

Warning

work in progress

Plugins offer a way to exchange the behaviour of some part of LBFextract’s workflow. This is achieved through a hook mechanism. LBFextract workflow for a feature extraction method is described in the core module and is composed by a sequence of steps. Each step is a hook. Hooks are implemented by plugins. The default behaviour of LBFextract is itself implemented in the fextract plugin, which provides the implementation of the coverage-based methods. Each hook is essentially a decorated python function. In LBFextract there are two types of hooks: CliHook and FextractHooks. The former is responsible for the implementation of the different steps of the workflow. The latter is responsible for the CLI commands needed to run the feature extraction method. The implementation in LBFextract is simplified through the “setup new-plugin” command. i.e. to create a new plugin extracting a “cool_signal_name” (the complete implementation of this example can be found here), one can run the following:

lbfextract setup new-plugin --name_of_the_signal cool_signal_name --name_of_the_cli_command extract_cool_signal_name --out_dir OUTPUTDIR

This will generate most of the boilerplate code needed to generate a LBFextract’s pluing. An LBFextract’s plugin includes the following files:

fextract-cool_signal_name
├── setup.py
├── src
│   ├── cool_signal_name
│      ├── __init__.py
│   └── └── plugin.py
└── tests

Once it has been generated, the plugin can be installed as follow:

cd OUTPUTDIR &&  python -m pip install .

after a successful installation, the extract_cool_signal_name command will be visible in the lbfextract feature_extraction_commands –help. At this moment the package won’t do much since the hooks are not implemented. To implement them one can open the generated plugin.py file. Inside, there is the definition of all hooks that can be implemented and the default CLI command. By default the CLI command is equal to the extract_coverage command, because this is the original definition of the hooks. Depending on the hooks that are implemented by the plugin, the CLI command has to be updated.

After generating the plugin the plugin.py file will look like this:

  1import logging
  2import pathlib
  3from typing import Any
  4from typing import List
  5from typing import Optional
  6
  7import click
  8import matplotlib
  9import pandas as pd
 10from lbfextract.utils_classes import Signal
 11
 12import lbfextract.fextract
 13from lbfextract.core import App
 14from lbfextract.fextract.schemas import Config, AppExtraConfig, ReadFetcherConfig, SingleSignalTransformerConfig, \
 15    SignalSummarizer
 16
 17logger = logging.getLogger(__name__)
 18
 19class FextractHooks:
 20
 21    @lbfextract.hookimpl
 22    def fetch_reads(self,
 23                    path_to_bam: pathlib.Path,
 24                    path_to_bed: pathlib.Path,
 25                    config: Any,
 26                    extra_config: Any) -> pd.DataFrame:
 27        """
 28        :param path_to_bam: path to the bam file
 29        :param path_to_bed: path to the bed file with the regions to be filtered
 30        :param config: configuration file containing the configuration object required by the fetch_reads function
 31        :param extra_config: extra configuration that may be used in the hook implementation
 32        :return: ReadsPerIntervalContainer object containing all the ReadsPerInterval objects in all the intervals
 33                 contained in the bed file
 34        """
 35        return None
 36
 37    @lbfextract.hookimpl
 38    def save_fatched_reads(self, reads_per_interval_container: pd.DataFrame,
 39                           config: Any,
 40                           extra_config: Any
 41                           ) -> None:
 42        """
 43        Hook implementing the strategy to save the reads fetched from the intervals
 44        :param reads_per_interval_container: ReadsPerIntervalContainer containing information about the genomic region
 45            and the reads mapping to it
 46        :param output_path: path to the location where the data should be stored
 47        :param id: run id
 48        :param time_stamp: time stamp
 49        :param extra_config: extra configuration that may be used in the hook implementation
 50
 51        :return: None
 52        """
 53        return None
 54
 55    @lbfextract.hookimpl
 56    def load_fetched_reads(self, config: Any, extra_config: AppExtraConfig) -> pd.DataFrame:
 57        """
 58        :param config: config specific to the function
 59        :param extra_config: extra configuration that may be used in the hook implementation
 60        """
 61        return None
 62
 63    @lbfextract.hookimpl
 64    def transform_reads(self, reads_per_interval_container: pd.DataFrame, config: Any,
 65                        extra_config: Any) -> pd.DataFrame:
 66        """
 67        :param reads_per_interval_container: ReadsPerIntervalContainer containing a list of ReadsPerInterval which are
 68            basically lists with information about start and end of the interval
 69        :param config: config specific to the function
 70        :param extra_config: extra configuration that may be used in the hook implementation
 71        """
 72        return None
 73
 74    @lbfextract.hookimpl
 75    def transform_single_intervals(self, transformed_reads: pd.DataFrame, config: Any,
 76                                   extra_config: Any) -> Signal:
 77        """
 78        :param transformed_reads: ReadsPerIntervalContainer containing a list of ReadsPerInterval which are
 79            basically lists with information about start and end of the interval
 80        :param config: config specific to the function
 81        :param extra_config: config containing context information plus extra parameters
 82        """
 83        return None
 84
 85    @lbfextract.hookimpl
 86    def transform_all_intervals(self, single_intervals_transformed_reads: Signal, config: Any,
 87                                extra_config: Any) -> Signal:
 88        """
 89        :param single_intervals_transformed_reads: Signal object containing the signals per interval
 90        :param config: config specific to the function
 91        :param extra_config: extra configuration that may be used in the hook implementation
 92        """
 93        return None
 94
 95    @lbfextract.hookimpl
 96    def plot_signal(self, signal: Signal, config: Any, extra_config: Any) -> matplotlib.figure.Figure:
 97        """
 98        :param signal: Signal object containing the signals per interval
 99        :param extra_config: extra configuration that may be used in the hook implementation
100        """
101        return None
102
103    @lbfextract.hookimpl
104    def save_signal(self,
105                    signal: Signal,
106                    config: Any,
107                    extra_config: Any) -> None:
108        """
109        :param signal: Signal object containing the signals per interval
110        :param extra_config: extra configuration that may be used in the hook implementation
111        """
112
113        return None
114
115
116class CliHook:
117    @lbfextract.hookimpl_cli
118    def get_command(self) -> click.Command | List[click.Command]:
119
120        @click.command()
121        @click.option('--path_to_bam', type=click.Path(exists=False,
122                                                       file_okay=True,
123                                                       dir_okay=True,
124                                                       writable=False,
125                                                       readable=True,
126                                                       resolve_path=False,
127                                                       allow_dash=True,
128                                                       path_type=pathlib.Path,
129                                                       executable=False),
130                      help='path to the bam file to be used')
131        @click.option('--path_to_bed', type=click.Path(exists=False,
132                                                       file_okay=True,
133                                                       dir_okay=True,
134                                                       writable=False,
135                                                       readable=True,
136                                                       resolve_path=False,
137                                                       allow_dash=True,
138                                                       path_type=pathlib.Path,
139                                                       executable=False),
140                      help='path to the bed file to be used')
141        @click.option('--output_path', type=click.Path(exists=False,
142                                                       file_okay=False,
143                                                       dir_okay=True,
144                                                       writable=True,
145                                                       readable=True,
146                                                       resolve_path=False,
147                                                       allow_dash=True,
148                                                       path_type=pathlib.Path,
149                                                       executable=False),
150                      help='path to the output directory')
151        @click.option("--skip_read_fetching", is_flag=True, show_default=True,
152                      help='Boolean flag. When it is set, the fetching of the reads is skipped and the latest'
153                           'timestamp of this run (identified by the id) is retrieved')
154        @click.option("--exp_id", default=None, type=str, show_default=True,
155                      help="run id")
156        @click.option("--window", default=1000, type=int, show_default=True,
157                      help="Integer describing the number of bases to be extracted around the middle point of an"
158                           " interval present in the bedfile")
159        @click.option("--flanking_window", default=1000, type=int, show_default=True,
160                      help="Integer describing the number of bases to be extracted after the window")
161        @click.option("--extra_bases", default=2000, type=int, show_default=True,
162                      help="Integer describing the number of bases to be extracted from the bamfile when removing the "
163                           "unused bases to be sure to get all the proper paires, which may be mapping up to 2000 bs")
164        @click.option("--n_binding_sites", default=1000, type=int, show_default=True,
165                      help="number of intervals to be used to extract the signal, if it is higher then the provided"
166                           "intervals, all the intervals will be used")
167        @click.option("--summarization_method", default="mean",
168                      type=click.Choice(["mean", "median", "max", "min", "skip"]),
169                      show_default=True,
170                      help=f"method to be used to summarize the signal: (Undefined, Undefined, Undefined, Undefined)")
171        @click.option("--cores", default=1, type=int, show_default=True,
172                      help="number of cores to be used")
173        @click.option("--flip_based_on_strand", is_flag=True,
174                      show_default=True,
175                      default=False,
176                      help="flip the signal based on the strand")
177        @click.option('--gc_correction_tag', type=str,
178                      default=None, help='tag to be used to extract gc coefficient per read from a bam file')
179
180        # Here you can add the options you want to be available in the command line (they should match the ones in the
181        # function)
182        def extract_cool_signal_name(
183             path_to_bam: pathlib.Path, path_to_bed: pathlib.Path,
184             output_path: pathlib.Path,
185             skip_read_fetching,
186             window: int,
187             flanking_window: int,
188             extra_bases: int,
189             n_binding_sites: int,
190             summarization_method: str,
191             cores: int,
192             exp_id: Optional[str],
193             flip_based_on_strand: bool,
194             gc_correction_tag: Optional[str]
195            ):
196            """
197            here yuo can add the code that will be executed when the command is called.
198            You can use the following code to customize the hooks
199            each hook receives a dictionary of configuration parameters
200            if the hook is implemented in the plugin, the parameters should be added in the correct hook.
201            Here you can find the parameters of the default hook (extract_coverage). These can be modified
202            depending on which hook you are using.
203            Hook specified above will be used in place of the default one. The first hook with a non None return value
204            will be used. Therfore the order of the plugins specified in the plugins_name list is important.
205            If you are inheriting from multiple signals, be sure to pass the correct parameters to the correct hooks.
206            Generally, parameters to be pass to hooks are shown in the schemas.py file of each plugin.
207            If you define a new hook for best practice you could inherit from lbfextract.fextract.schemas and specify
208            the voluptuous schema for the parameters of the hook.
209            """
210            read_fetcher_config = {
211                "window": window,
212                "flanking_region_window": flanking_window,
213                "extra_bases": extra_bases,
214                "n_binding_sites": n_binding_sites
215            }
216            reads_transformer_config = {}
217            single_signal_transformer_config = {
218                "signal_transformer": "coverage",
219                "flip_based_on_strand": flip_based_on_strand,
220                "gc_correction": True if gc_correction_tag else False,
221                "tag": gc_correction_tag
222
223            }
224            transform_all_intervals_config = {
225                "summarization_method": summarization_method
226            }
227            plot_signal_config = {}
228            save_signal_config = {}
229            extra_config = {
230                "cores": cores
231            }
232            output_path.mkdir(parents=True, exist_ok=True)
233            output_path_interval_spec = output_path / f"{path_to_bam.stem}" / f"{path_to_bed.stem}"
234            output_path_interval_spec.mkdir(parents=True, exist_ok=True)
235            res = App(plugins_name=["cool_signal_name", ],
236                      path_to_bam=path_to_bam,
237                      path_to_bed=path_to_bed,
238                      output_path=output_path_interval_spec or pathlib.Path.cwd(),
239                      skip_read_fetching=skip_read_fetching,
240                      read_fetcher_config=ReadFetcherConfig(read_fetcher_config),
241                      reads_transformer_config=Config(reads_transformer_config),
242                      single_signal_transformer_config=SingleSignalTransformerConfig(single_signal_transformer_config),
243                      transform_all_intervals_config=SignalSummarizer(transform_all_intervals_config),
244                      plot_signal_config=Config(plot_signal_config),
245                      save_signal_config=Config(save_signal_config),
246                      extra_config=AppExtraConfig(extra_config),
247                      id=exp_id).run()
248            return res
249
250        return extract_cool_signal_name
251
252hook = FextractHooks()
253hook_cli = CliHook()

Assuming the cool signal is just the number of reads in each interval, one could implement this by replacing the transform_single_intervals and transform_all_intervals hooks as follow:

 1@lbfextract.hookimpl
 2def transform_single_intervals(self, transformed_reads: pd.DataFrame, config: Any,
 3                               extra_config: Any) -> Signal:
 4    """
 5    :param transformed_reads: ReadsPerIntervalContainer containing a list of ReadsPerInterval which are
 6        basically lists with information about start and end of the interval
 7    :param config: config specific to the function
 8    :param extra_config: config containing context information plus extra parameters
 9    """
10    # transformed_reads is a pandas DataFrame with the following columns:
11    # 'Chromosome', 'Start', 'End', 'Name', 'Score', 'Strand', 'reads_per_interval'
12    # and reads_per_interval contains pysam.libcalignmentfile.IteratorRowRegion for each interval
13
14    def count_rads_in_interval(x: pysam.libcalignmentfile.IteratorRowRegion) -> int:
15        return len(list(x))
16
17    transformed_reads.index = transformed_reads.Chromosome.astype("str") + "-" + transformed_reads.Start.astype(
18    "str") + "-" + transformed_reads.End.astype("str")
19
20    reads_per_interval_pd_series = transformed_reads.reads_per_interval.apply(lambda x: count_rads_in_interval(x))
21
22    return Signal(reads_per_interval_pd_series.values,
23                  metadata=reads_per_interval_pd_series.index.copy(),
24                  tags=tuple(["cool_signal", ]))
25
26@lbfextract.hookimpl
27def transform_all_intervals(self, single_intervals_transformed_reads: Signal, config: Any,
28                            extra_config: Any) -> Signal:
29    """
30    :param single_intervals_transformed_reads: Signal object containing the signals per interval
31    :param config: config specific to the function
32    :param extra_config: extra configuration that may be used in the hook implementation
33    """
34    return single_intervals_transformed_reads

In this way when executing:

lbfextract feature_extraction_commands extract-cool-signal-name --path_to_bam <path_to_bam> --path_to_bed <path_to_bed> --output_path <output_path>

after fetching the reads, lbfextract will execute the newly implemented hooks in place of the default ones. One can use already implemented hooks in other plugins by registering them in the App.plugins_name attribute. This accepts a list of plugins. Hooks are parsed from left to right, and only the first not None hook will be run. i.e. if there are 2 plugins implementing the plot_signal hook, which are registered in the App object as follow: App.plugins_name([“plug2”, “plug1”]), only plug2 plot_signal hook will be run. New parameters can be provided in form of config. Config can be dict object but LBFextract provides also a Config object, which offers a way of validating the provided parameters with a voluptuous schema. One can subclass the Config class providing a voluptuous schema. One can inspect the lbfextract.fextract.schema module for an example.