GraDiv 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gradiv/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ def get_version():
2
+ from importlib.metadata import version
3
+ return version("gradiv")
4
+
5
+ __version__ = get_version()
6
+
7
+ #add import classes
gradiv/_version.py ADDED
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.1.0'
21
+ __version_tuple__ = version_tuple = (0, 1, 0)
gradiv/algo.py ADDED
@@ -0,0 +1,163 @@
1
+ import gradiv.pggraph as pggraph
2
+ import gradiv.polymorphism as polymorphism
3
+ from pybedtools import BedTool
4
+ import typing
5
+ import logging
6
+
7
+ def call_polymorphisms (graph_index:'pggraph.GraphIndex', id_table:bool=True, seq_table:bool=True) -> int :
8
+ """
9
+ First version of the "child" algorithm. Detects polymorphisms in a pangenome graph and outputs them in CSV files.
10
+ :param graph_index: GraphIndex instance
11
+ :param id_table : Flag to output genotype table with node IDs
12
+ :param seq_table : Flag to output genotype table with sequences
13
+ :return: None
14
+ """
15
+ logger = logging.getLogger("gradiv")
16
+ logger.debug("Calling polymorphism functions")
17
+
18
+ # output polymorphisms to a genotype table file
19
+ header = "previous_node"
20
+ for sample in graph_index.samples:
21
+ header += f',{sample}'
22
+ header += '\n'
23
+ f_id = None
24
+ f_seq = None
25
+ if id_table:
26
+ f_id = open(str(graph_index.id_table_path), 'w')
27
+ f_id.write(header)
28
+ if seq_table:
29
+ f_seq = open(str(graph_index.seq_table_path), 'w')
30
+ f_seq.write(header)
31
+
32
+ # scan the graph for atomic polymorphisms
33
+ nb_poly = 0
34
+ for segment in graph_index.bam_file_for_parsing :
35
+ # initialize variables
36
+ reversed_parent = None
37
+ is_reversed = False
38
+
39
+ parent = pggraph.Node(bam_segment=segment, sense=1)
40
+ parent.current_nodes = {parent.get_full_id(): parent}
41
+ # get all nodes implicated
42
+ links, forward_nodes, reverse_nodes, stop = parent.get_links(graph_index=graph_index)
43
+ if stop:
44
+ continue
45
+ nodes_both_senses = forward_nodes.intersection(reverse_nodes)
46
+ if parent.identifier in nodes_both_senses:
47
+ is_reversed = True
48
+ reversed_parent = pggraph.Node(bam_segment=segment, sense=-1)
49
+ reversed_parent.current_nodes = parent.current_nodes
50
+ parent.current_nodes[reversed_parent.get_full_id()] = reversed_parent
51
+
52
+ # create nodes
53
+ parent.create_nodes(nodes_id_set=forward_nodes, sense=1, graph_index=graph_index)
54
+ parent.create_nodes(nodes_id_set=reverse_nodes, sense=-1, graph_index=graph_index)
55
+
56
+ # resolve segments' samples when in both senses
57
+ for node_id in nodes_both_senses:
58
+ parent.current_nodes[f'+{node_id}'].samples, parent.current_nodes[f'-{node_id}'].samples = get_samples_per_sense(parent.current_nodes[f'+{node_id}'], graph_index=graph_index)
59
+
60
+ parent.add_children(links=links)
61
+ parent.add_parents(graph_index=graph_index)
62
+
63
+ # call polymorphisms
64
+ poly = call_polymorphism_from_parent(parent)
65
+ if poly.node_per_sample:
66
+ write_out_polymorphism(poly, parent, graph_index, f_id, f_seq)
67
+ nb_poly += 1
68
+ if is_reversed:
69
+ poly = call_polymorphism_from_parent(reversed_parent)
70
+ if poly.node_per_sample:
71
+ write_out_polymorphism(poly, reversed_parent, graph_index, f_id, f_seq)
72
+ nb_poly += 1
73
+ if f_id:
74
+ f_id.close()
75
+ if f_seq:
76
+ f_seq.close()
77
+ return nb_poly
78
+
79
+
80
+ def get_samples_per_sense (node:pggraph.Node, graph_index:pggraph.GraphIndex) -> tuple[set, set] :
81
+ """
82
+ Associate samples to the forward or reverse segment. Used only when both segments are present.
83
+ :param node: Node instance
84
+ :param graph_index: GraphIndex instance
85
+ :return: tuple of sets of samples (forward, reverse)
86
+ """
87
+ segment_info = (set(), set())
88
+ for sample in node.samples:
89
+ bed_file = BedTool(str(graph_index.bed_files[sample]))
90
+ sample_node_presence = search_bed(node.identifier, bed_file)
91
+ if sample_node_presence[0] :
92
+ segment_info[0].add(sample)
93
+ elif sample_node_presence[1] :
94
+ segment_info[1].add(sample)
95
+ else:
96
+ raise ValueError("Segment not found")
97
+ return segment_info
98
+
99
+ def search_bed (node_id:str, bed_file:BedTool) -> tuple[bool, bool] :
100
+ """
101
+ Browse BED files and return presence or absence of forward and reverse given segment per sample
102
+ :param node_id: Full node ID (sign and ID)
103
+ :param bed_file: pybedtools BED file
104
+ :return: tuple of booleans (forward, reverse)
105
+ """
106
+ presence_forward = False
107
+ presence_reverse = False
108
+ for interval in bed_file:
109
+ if len(interval.fields) >= 4:
110
+ segment_id = interval.name
111
+ if segment_id[1:] == node_id:
112
+ if segment_id[0] == '+':
113
+ presence_forward = True
114
+ else:
115
+ presence_reverse = True
116
+ else:
117
+ raise ValueError("Incorrect BED format")
118
+ if presence_reverse and presence_forward:
119
+ return presence_forward, presence_reverse
120
+ return presence_forward, presence_reverse
121
+
122
+ def call_polymorphism_from_parent(parent:pggraph.Node) -> polymorphism.Polymorphism :
123
+ """
124
+ Call potential polymorphism right after parent node.
125
+ :param parent: Node instance
126
+ :return: Polymorphism instance
127
+ """
128
+ poly = polymorphism.Polymorphism()
129
+ grand_children = parent.get_grand_children()
130
+ for gc_id in grand_children:
131
+ substitution_nodes = parent.current_nodes[gc_id].parents.intersection(parent.children)
132
+ if len(substitution_nodes) > 1:
133
+ poly.add_substitution(parent, substitution_nodes, parent.current_nodes[gc_id])
134
+ if gc_id in parent.children:
135
+ poly.add_deletion(parent, parent.current_nodes[gc_id])
136
+ if len(substitution_nodes) == 1:
137
+ poly.add_substitution(parent, substitution_nodes, parent.current_nodes[gc_id])
138
+ return poly
139
+
140
+ def write_out_polymorphism(poly:polymorphism.Polymorphism, parent:pggraph.Node, graph_index:pggraph.GraphIndex, f_id:typing.TextIO=None, f_seq:typing.TextIO=None) -> None:
141
+ """
142
+ Write a single polymorphism to genotype tables.
143
+ :param poly: Polymorphism instance
144
+ :param parent: Node instance associated with the polymorphism
145
+ :param graph_index: GraphIndex instance
146
+ :param f_id: Flag to output genotype table with node IDs
147
+ :param f_seq: Flag to output genotype table with sequences
148
+ :return: None
149
+ """
150
+ poly.add_undefined_values(graph_index.samples)
151
+ if f_id:
152
+ f_id.write(parent.get_full_id())
153
+ for sample in graph_index.samples:
154
+ f_id.write(f',{poly.node_per_sample[sample]}')
155
+ f_id.write('\n')
156
+ if f_seq:
157
+ f_seq.write(parent.get_full_id())
158
+ for sample in graph_index.samples:
159
+ if poly.node_per_sample[sample] in ["?", "*"]:
160
+ f_seq.write(f',{poly.node_per_sample[sample]}')
161
+ else:
162
+ f_seq.write(f',{parent.current_nodes[poly.node_per_sample[sample]].sequence}')
163
+ f_seq.write('\n')
gradiv/functions.py ADDED
@@ -0,0 +1,91 @@
1
+ from sys import argv
2
+ from cloup import HelpFormatter, HelpTheme, Style, Context
3
+ import logging
4
+ from rich.logging import RichHandler
5
+ from rich.traceback import install
6
+ from gradiv.__init__ import __version__
7
+
8
+
9
+ # context settings for gradiv command
10
+ CONTEXT_SETTINGS = Context.settings(
11
+ help_option_names=('-h', '--help'),
12
+ ignore_unknown_options=False,
13
+ align_option_groups=True,
14
+ align_sections=True,
15
+ show_constraints=True,
16
+ formatter_settings=HelpFormatter.settings(
17
+ max_width=120,
18
+ col1_max_width=40,
19
+ col2_min_width=60,
20
+ indent_increment=2,
21
+ col_spacing=1,
22
+ row_sep=None,
23
+ theme=HelpTheme.dark().with_(
24
+ invoked_command=Style(fg='bright_yellow'),
25
+ command_help=Style(fg='bright_cyan'),
26
+ heading=Style(fg='green', bold=True),
27
+ constraint=Style(fg='magenta'),
28
+ section_help=Style(fg='red'),
29
+ col1=Style(fg='bright_cyan'),
30
+ col2=Style(fg='white'),
31
+ epilog=Style(fg="bright_white", italic=True)
32
+ )
33
+ )
34
+ )
35
+
36
+ def init_log(verbosity:str="INFO") -> logging.Logger:
37
+ """
38
+ Initialize the logger and configure console and file output.
39
+ :param str verbosity: Logging verbosity level.
40
+ :return: Logger object.
41
+ """
42
+ verbosity = verbosity.upper()
43
+ debug = False
44
+ if verbosity == 'DEBUG':
45
+ debug = True
46
+ install(show_locals=True)
47
+
48
+ # create console handler
49
+ rich_handler = RichHandler(
50
+ rich_tracebacks=True if debug else False,
51
+ markup=True,
52
+ show_time=False,
53
+ show_level=False,
54
+ show_path=True if debug else False,
55
+ # console=shared_console
56
+ )
57
+
58
+ # create file handler (a: append / w: write)
59
+ file_handler = logging.FileHandler('gradiv.log', mode='w', encoding='utf-8')
60
+ file_handler.setLevel(logging.DEBUG) # level debug and above
61
+
62
+ # set format
63
+ formatter = logging.Formatter(
64
+ fmt=f'%(asctime)s | {"%(name)-8s | %(funcName)-22s |" if debug else ""} %(levelname)-8s | %(message)s',
65
+ datefmt='%m-%d %H:%M'
66
+ )
67
+ file_handler.setFormatter(formatter)
68
+ rich_handler.setFormatter(formatter)
69
+
70
+ # initialize logger and set verbosity level
71
+ logger = logging.getLogger("gradiv")
72
+ logger.setLevel(getattr(logging, verbosity))
73
+
74
+ if not logger.handlers:
75
+ logger.addHandler(file_handler)
76
+ logger.addHandler(rich_handler)
77
+
78
+ logger.info("Starting GraDiv")
79
+ logger.info(f"GraDiv version: {__version__}")
80
+ logger.info(f"Verbosity level: {verbosity}")
81
+ logger.info(f"Command line run: {' '.join(argv)}")
82
+ return logger
83
+
84
+
85
+
86
+ def format_stat_output(x) -> str:
87
+ """Format statistics in output file for gradiv compute."""
88
+ if isinstance(x, float): return format(x, '.2f')
89
+ if x is None: return 'NA'
90
+ if isinstance(x, (list, tuple)): return f'({" ".join(map(format_stat_output, x))})'
91
+ return str(x)
gradiv/main.py ADDED
@@ -0,0 +1,241 @@
1
+ import click
2
+ from cloup import group, option, command, option_group
3
+ from cloup.constraints import ErrorFmt, RequireExactly
4
+ import egglib
5
+ from time import time
6
+ from gradiv.__init__ import __version__
7
+ import gradiv.algo as algo
8
+ import gradiv.pggraph as pggraph
9
+ from gradiv.functions import *
10
+
11
+ start_time = time()
12
+
13
+
14
+
15
+ # OPTION GROUPS
16
+
17
+ common_options = [
18
+ option("-v", "--verbosity", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False), default="INFO"),
19
+ option("-g", "--gratools-index", type=click.Path(exists=True, file_okay=False), required=True, help="Path to GraTools index"),
20
+ ]
21
+
22
+ stat_options = RequireExactly(1).rephrased(
23
+ help="""The compute command needs a list of statistics to compute. One of those options has to be provided.""",
24
+ error=f'Exactly one statistic parameter must be provided:\n{ErrorFmt.param_list}'
25
+ )
26
+
27
+
28
+
29
+ # GRADIV COMMANDS
30
+
31
+ @group("gradiv",
32
+ help="A tool for diversity analysis of pangenome graphs",
33
+ no_args_is_help=True,
34
+ invoke_without_command=True,
35
+ context_settings=CONTEXT_SETTINGS)
36
+ @click.version_option(__version__, "-v", "--version", message="%(prog)s, version %(version)s")
37
+ def main_command(verbosity:str="INFO"):
38
+ pass
39
+
40
+
41
+ @command("call",
42
+ short_help="Call polymorphisms from a GraTools pangenome graph index",
43
+ help="Call polymorphisms from a GraTools index and output them as genotype tables in a GraDiv directory",
44
+ no_args_is_help=True)
45
+ @option_group("call options", *common_options)
46
+ def call(gratools_index, verbosity:str="INFO"):
47
+ """Generate genotype tables from a GraTools index and outputs them in a GraDiv directory"""
48
+ logger = init_log(verbosity)
49
+
50
+ gfa_index = pggraph.GraphIndex(gratools_index)
51
+ gfa_index.make_gradiv_directory()
52
+ gfa_index.make_bam_index_on_reads()
53
+ gfa_index.open_bam_for_parsing()
54
+
55
+ logger.info(f'Calling polymorphisms from "{gfa_index.gratools_directory}"')
56
+ logger.info(f'Output directory : {gfa_index.gradiv_directory}')
57
+
58
+ # call polymorphisms
59
+ nb_poly = algo.call_polymorphisms(graph_index=gfa_index, id_table=True, seq_table=True)
60
+
61
+ execution_time = time() - start_time
62
+ logger.info(f'Found {nb_poly} polymorphisms')
63
+ logger.info(f'Executed in {execution_time:.4f} seconds')
64
+
65
+ gfa_index.close()
66
+
67
+
68
+ @command("compute",
69
+ short_help="Compute statistics from a genotype table",
70
+ help="""Compute given statistics from a GraTools index and a genotype table generated with gradiv call.
71
+ To know which statistics ans options to provide, check out EggLib's documentation : https://www.egglib.org/.
72
+ Analysis can be run on a specific region (query sample will be required).
73
+ For more information, see GraDiv's documentation : https://forge.ird.fr/phim/gradiv.
74
+ """,
75
+ no_args_is_help=True,
76
+ show_constraints=True)
77
+ @option_group("compute options",
78
+ *common_options,
79
+ stat_options(
80
+ option("--stat-list", type=str, required=False, help="Comma separated list of statistics to compute"),
81
+ option("--stat-type", type=str, required=False, help="Type of statistic to compute (can be a comma-separated list)"),
82
+ option("--stat-file", type=click.Path(exists=True), required=False, help="Path to file providing statistics to compute")
83
+ ),
84
+ option("-q", "--query", type=str, required=False, help="Sample name of query sequence to use as reference coordinates"),
85
+ option("-c", "--chrom", type=str, required=False, help="Chromosome to use as reference coordinates"),
86
+ option("-s", "--start-position", type=click.IntRange(min=0), required=False, help="Start position of specific region to compute statistics on"),
87
+ option("-e", "--end-position", type=click.IntRange(min=0), required=False, help="End position of specific region to compute statistics on"),
88
+ option("--structure", type=click.Path(exists=True), required=False, help="Path to file providing sample structure"),
89
+ option("--max-missing", type=int, required=False, help="Ignore polymorphisms with more missing data than this threshold"),
90
+ option("--maf", type=float, default=0, required=False, help="Ignore polymorphisms where the relative minority allele frequency is below this threshold"),
91
+ option("--only-diallelic", is_flag=True, required=False, help="Ignore polymorphisms with more than two alleles"))
92
+ def compute(gratools_index, query: str = None, chrom: str = None, start_position: int = None, end_position: int = None,
93
+ structure=None, stat_list: str = None, stat_type: str = None,
94
+ stat_file=None, max_missing: int = None, maf: float = 0, only_diallelic: bool = False,
95
+ verbosity: str = "INFO"):
96
+ """Compute statistics from a GraDiv directory."""
97
+ logger = init_log(verbosity)
98
+
99
+ gfa_index = pggraph.GraphIndex(gratools_index)
100
+ gfa_index.make_bam_index_on_reads()
101
+
102
+ logger.info(f'Computing statistics from {gfa_index.id_table_path}')
103
+ logger.info(f'Output file : {gfa_index.stats_path}')
104
+
105
+ # open genotype table
106
+ f = open(str(gfa_index.id_table_path), "r")
107
+ f.readline()
108
+
109
+ # configure ComputeStats
110
+ cs = egglib.stats.ComputeStats(multi=True, multi_hits=False)
111
+ cs.configure(maf=maf)
112
+
113
+ # get structure
114
+ if structure:
115
+ structure_file = open(structure, "r")
116
+ ingroup_dict = {}
117
+ outgroup_dict = {}
118
+ samples = []
119
+ fields = structure_file.readline().strip().split(',')
120
+ field_to_index = {}
121
+ sample_index = 0
122
+ for i in range(len(fields)):
123
+ field_to_index[fields[i]] = i
124
+
125
+ legal_fields = {'cluster', 'population', 'outgroup'}
126
+ if fields[0] != 'sample':
127
+ logger.error('Structure file : First field must be "sample"')
128
+ raise ValueError('Structure file : First field must be "sample"')
129
+ for field in fields[1:]:
130
+ if field not in legal_fields:
131
+ logger.error(f'Structure file : "{field}" is not a valid field')
132
+ raise ValueError(f'Structure file : "{field}" is not a valid field')
133
+
134
+ for line in structure_file:
135
+ line = line.strip().split(',')
136
+ samples.append(line[0])
137
+ if 'outgroup' in fields and line[field_to_index['outgroup']] == "yes":
138
+ outgroup_dict[line[0]] = [sample_index] # list of a single element as only haploidy is supported
139
+ else:
140
+ if 'cluster' not in fields:
141
+ cluster = None
142
+ else:
143
+ cluster = line[field_to_index['cluster']]
144
+ if cluster not in ingroup_dict.keys():
145
+ ingroup_dict[cluster] = {}
146
+ if line[field_to_index['population']] not in ingroup_dict[cluster].keys():
147
+ ingroup_dict[cluster][line[field_to_index['population']]] = {}
148
+ ingroup_dict[cluster][line[field_to_index['population']]][line[0]] = [sample_index] # list of a single element as only haploidy is supported
149
+ sample_index += 1
150
+
151
+ # check if all samples are included
152
+ samples.sort()
153
+ if samples != gfa_index.samples:
154
+ logging.error('Structure file : incorrect sample names or missing samples')
155
+ raise ValueError('Structure file : incorrect sample names or missing samples')
156
+
157
+ struct = egglib.Structure()
158
+ struct.from_dict(ingroup_dict, outgroup=outgroup_dict)
159
+ cs.configure(struct=struct)
160
+
161
+ # passing statistics to compute
162
+ if stat_list :
163
+ cs.add_stats(*stat_list.split(","))
164
+ elif stat_type :
165
+ cs.add_stats(*[f"+{item}" for item in stat_type.split(",")])
166
+ elif stat_file :
167
+ open_stat_file = open(str(stat_file), "r")
168
+ stat_list_from_file = open_stat_file.readline().strip().split(",")
169
+ cs.add_stats(*stat_list_from_file)
170
+ open_stat_file.close()
171
+ #TODO: add debug : computed stats
172
+
173
+ by_position = False
174
+ if query and chrom:
175
+ by_position = True
176
+ logger.debug('Computing statistics by position')
177
+ else:
178
+ if query or chrom or start_position or end_position:
179
+ logger.error('Both query and chrom options are needed to compute statistics by region.')
180
+ raise ValueError('Both query and chrom options are needed to compute statistics by region.')
181
+
182
+ # compute stats
183
+ for line in f:
184
+ anchor_node = line.rstrip().split(',')[0]
185
+ allele_position = None
186
+ if by_position:
187
+ allele_position = gfa_index.get_site_position(node_id=anchor_node, query=query, chrom=chrom)
188
+ if allele_position == -1:
189
+ continue
190
+ if start_position and end_position and (allele_position < start_position or allele_position > end_position):
191
+ continue
192
+ split_line = line.rstrip().split(',')[1:]
193
+ sequences = []
194
+ for part in split_line:
195
+ if part not in ["?", "*"] :
196
+ segment = gfa_index.query_segment(part[1:])
197
+ if part[0] == '+' :
198
+ sequences.append(segment.query_sequence)
199
+ else:
200
+ sequences.append(pggraph.Node.reverse_complement(segment.query_sequence))
201
+ else :
202
+ sequences.append(part)
203
+ alleles = set(sequences) #TODO: get polymorphism type from it (filter on type/length)
204
+ alleles.discard("?")
205
+ alph = egglib.Alphabet("string", list(alleles), ["?"])
206
+ site = egglib.site_from_list(sequences, alph)
207
+ if by_position:
208
+ site.position = allele_position
209
+ # filter out polymorphisms
210
+ ignore_site = False
211
+ if max_missing is not None and site.num_missing > max_missing:
212
+ ignore_site=True
213
+ if only_diallelic and alph.num_exploitable > 2 :
214
+ ignore_site=True
215
+ if not ignore_site:
216
+ cs.process_site(site)
217
+ logger.debug('Computing all statistics')
218
+ stats = cs.results()
219
+ logger.debug('Statistics computed')
220
+
221
+ # outputs stats in CSV file
222
+ stat_names = []
223
+ for stat_name in stats.keys():
224
+ stat_names.append(stat_name)
225
+
226
+ canon_stat_order = [key for grp in ('site', 'unphased', 'phased', 'allelesize') for key in cs.stats_group(grp)]
227
+ stat_names.sort(key=canon_stat_order.index)
228
+
229
+ f_stats = open(str(gfa_index.stats_path), 'w')
230
+ f_stats.write("stat_name,value\n")
231
+ for stat_name in stat_names:
232
+ f_stats.write(f'{stat_name},{format_stat_output(stats[stat_name])}\n')
233
+ f_stats.close()
234
+
235
+ execution_time = time() - start_time
236
+ logger.info(f'Executed in {execution_time:.4f} seconds')
237
+
238
+
239
+ # add commands to main
240
+ main_command.add_command(call)
241
+ main_command.add_command(compute)