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/pggraph.py ADDED
@@ -0,0 +1,361 @@
1
+ import pysam
2
+ import sqlite3
3
+ from pathlib import Path
4
+ import shutil
5
+ from pybedtools import BedTool
6
+
7
+ d_sens = {1:'+', -1:'-', '+':1, '-':-1} #used to convert segment sense from numeric to symbol and vice versa
8
+
9
+ class Node:
10
+ """
11
+ Node class representing a segment in a pangenome graph.
12
+
13
+ Attributes:
14
+ identifier: segment identifier (without sense)
15
+ sense: sense of the segment (1: forward, -1: reverse)
16
+ sequence: sequence of the segment
17
+ samples: set of samples using the segment (only genome)
18
+ complete_samples: set of samples using the segment (genome, chromosome and haplotype)
19
+ current_nodes: dictionary storaging all nodes used: parent(s), children and grandchildren (only used in parent node)
20
+ children: set of children nodes' full IDs (+/-ID)
21
+ parents: set of parents nodes' full IDs (+/-ID)
22
+ """
23
+ def __init__(self, bam_segment:pysam.libcalignedsegment.AlignedSegment, sense:int) -> None:
24
+ """
25
+ Initialize node
26
+ :param bam_segment: pysam BAM aligned segment from a GraTools index
27
+ :param graph_index: GraphIndex instance
28
+ :param sense: Sense of the segment: 1/-1
29
+ """
30
+ self.identifier = bam_segment.query_name
31
+ self.sense = sense
32
+ if sense == -1:
33
+ self.sequence = Node.reverse_complement(bam_segment.query_sequence)
34
+ else :
35
+ self.sequence = bam_segment.query_sequence
36
+ self.samples = None
37
+ self.add_samples(bam_segment)
38
+ self.complete_samples = None
39
+ self.add_complete_samples(bam_segment)
40
+ self.current_nodes = None # only used for parent node
41
+ self.children = set()
42
+ self.parents = set()
43
+
44
+ def __str__(self) -> str:
45
+ return f'Node {self.get_full_id()} : {self.sequence}. Parents: {self.parents}. Children: {self.children}. Samples: {self.samples}.'
46
+
47
+ def __repr__(self) -> str:
48
+ return f'Node {self.get_full_id()}'
49
+
50
+ def add_samples (self, bam_segment) -> None:
51
+ """
52
+ Add samples supported by the node (only account sample name)
53
+ :param bam_segment: BAM segment
54
+ :return: None
55
+ """
56
+ txt_samples = bam_segment.get_tag('SW')
57
+ samples_set = set(elm.split(";")[0] for elm in txt_samples.split(','))
58
+ self.samples = samples_set
59
+
60
+ def add_complete_samples (self, bam_segment) -> None:
61
+ """
62
+ Add samples supported by the node (account sample name, chromosome and haplotype)
63
+ :param bam_segment: BAM segment
64
+ :return:None
65
+ """
66
+ txt_samples = bam_segment.get_tag('SW')
67
+ samples = txt_samples.split(',')
68
+ samples_set = set(tuple(s.split(';')) for s in samples)
69
+ self.complete_samples = samples_set
70
+
71
+ def add_children(self, links:dict) -> None:
72
+ """
73
+ Add children nodes' full IDs to children attribute
74
+ :param links: Dictionnary of current parent->child links
75
+ :return: None
76
+ """
77
+ for parent_node in self.current_nodes.keys():
78
+ if parent_node in links.keys():
79
+ children = links[parent_node]
80
+ for child in children:
81
+ if self.current_nodes[child].samples.intersection(self.current_nodes[parent_node].samples):
82
+ if not self.current_nodes[parent_node].children:
83
+ self.current_nodes[parent_node].children = {child}
84
+ else:
85
+ self.current_nodes[parent_node].children.add(child)
86
+
87
+ def add_parents(self, graph_index:'GraphIndex') -> None:
88
+ """
89
+ Add parents nodes' full IDs to parents attribute
90
+ :param graph_index: GraphIndex instance
91
+ :return: None
92
+ """
93
+ for grandchild in self.get_grand_children():
94
+ self.current_nodes[grandchild].parents = set()
95
+ for link in graph_index.query_links(grandchild[1:]):
96
+ if link[3] == grandchild[1:]:
97
+ if link[0] == grandchild[1:]:
98
+ raise ValueError('Cycle detected')
99
+ else:
100
+ self.current_nodes[grandchild].parents.add(f'{d_sens[link[1]]}{link[0]}')
101
+
102
+
103
+ def get_links(self, graph_index:'GraphIndex') -> tuple:
104
+ """
105
+ Get all current parent->child links as a dictionary with IDs as keys and set of IDs as items.
106
+ Returns 4 variables : links dictionary, forward nodes as a set, reverse nodes as a set and boolean (True if not enough children to find a bubble).
107
+ :param graph_index: GraphIndex instance
108
+ :return: tuple of a links dictionary, forward set and reverse set
109
+ """
110
+ links = {}
111
+ children = set()
112
+ forward_nodes = set()
113
+ reverse_nodes = set()
114
+ n_children_forward = 0
115
+ n_children_reverse = 0
116
+ # parse parent's children
117
+ for link in graph_index.query_links(self.identifier):
118
+ if link[0] == self.identifier: # only take links where the node is parent
119
+ parent_id = d_sens[link[1]] + link[0]
120
+ if link[3] == self.identifier:
121
+ raise ValueError('Cycle detected')
122
+ else:
123
+ if link[1] == 1:
124
+ forward_nodes.add(link[0])
125
+ n_children_forward += 1
126
+ else:
127
+ reverse_nodes.add(link[0])
128
+ n_children_reverse += 1
129
+ child_id = d_sens[link[4]] + link[3]
130
+ children.add(child_id)
131
+ if link[4] == 1:
132
+ forward_nodes.add(link[3])
133
+ else:
134
+ reverse_nodes.add(link[3])
135
+ if parent_id not in links.keys():
136
+ links[parent_id] = {child_id}
137
+ else :
138
+ links[parent_id].add(child_id)
139
+ if n_children_forward + n_children_reverse < 2 :
140
+ return links, forward_nodes, reverse_nodes, True
141
+ # parse children's children
142
+ for child in children:
143
+ for link in graph_index.query_links(child[1:]):
144
+ if link[0] == child[1:]: # only take links where the node is parent
145
+ if link[3] == child[1:]:
146
+ raise ValueError('Cycle detected')
147
+ else:
148
+ grand_child_id = d_sens[link[4]] + link[3]
149
+ if link[4] == 1:
150
+ forward_nodes.add(link[3])
151
+ else:
152
+ reverse_nodes.add(link[3])
153
+ if child not in links.keys():
154
+ links[child] = {grand_child_id}
155
+ else :
156
+ links[child].add(grand_child_id)
157
+ return links, forward_nodes, reverse_nodes, False
158
+
159
+ def create_nodes(self, nodes_id_set:set[str], sense:int, graph_index:'GraphIndex') -> None:
160
+ """
161
+ Create nodes and store them in a dictionary (current_nodes attribute)
162
+ :param nodes_id_set: Set of node IDs (without sense)
163
+ :param sense: Sense + or -
164
+ :param graph_index: GraphIndex instance
165
+ :return: None
166
+ """
167
+ for node_id in nodes_id_set:
168
+ full_id = str(d_sens[sense]) + node_id
169
+ if full_id not in self.current_nodes.keys():
170
+ segment = graph_index.query_segment(segment_id=node_id)
171
+ self.current_nodes[full_id] = Node(bam_segment=segment, sense=sense)
172
+
173
+ def get_grand_children(self) -> set[str] :
174
+ """
175
+ :return: Dictionary of all grand children nodes
176
+ """
177
+ grand_children = set()
178
+ for child_id in self.children:
179
+ for grandchild_id in self.current_nodes[child_id].children:
180
+ grand_children.add(grandchild_id)
181
+ return grand_children
182
+
183
+ def get_full_id(self):
184
+ """
185
+ :return: Full ID of the node (+/-ID)
186
+ """
187
+ return f'{d_sens[self.sense]}{self.identifier}'
188
+
189
+ def display_filiation(self):
190
+ """
191
+ Display the node's filiation tree (from parent to granchildren)
192
+ """
193
+ print(self.get_full_id())
194
+ for child in self.children:
195
+ print('\t|_', child)
196
+ for grandchild in self.current_nodes[child].children:
197
+ print('\t\t|_', grandchild)
198
+
199
+ @classmethod
200
+ def reverse_complement(cls, sequence:str) -> str:
201
+ """
202
+ :param sequence: DNA sequence
203
+ :return: Reverse complement of the sequence
204
+ """
205
+ rc_seq = ''
206
+ complements = {'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A'}
207
+ for c in reversed(sequence):
208
+ if c in complements.keys():
209
+ rc_seq += complements[c]
210
+ else:
211
+ rc_seq += c
212
+ return rc_seq
213
+
214
+ @classmethod
215
+ def shared_samples(cls, node1:'Node', *args:'Node') -> set['Node']:
216
+ """
217
+ Get shared samples between two nodes or more
218
+ :param node1: Node
219
+ :param args: Node(s)
220
+ :return: Set of samples
221
+ """
222
+ #TODO: base off genome + chr + haplotype
223
+ intersection = node1.samples.copy()
224
+ for node in args:
225
+ intersection.intersection_update(node.samples)
226
+ return intersection
227
+
228
+
229
+
230
+ class GraphIndex :
231
+ """
232
+ Class representing a GraTools index and GraDiv genotype tables
233
+
234
+ Attributes:
235
+ gratools_directory: Path to GraTools index directory
236
+ pg_name: Pangenome graph name
237
+ samples: list of samples (used for outputs)
238
+ bam_path: path to BAM file
239
+ bam_file: open pysam alignment file
240
+ bam_index: index of BAM file on segment ID
241
+ bam_file_for_parsing: second open pysam alignment file for linear parsing
242
+ bed_directory_path: path to BED files directory
243
+ bed_files: dictionary of BED files path with sample name as key
244
+ db_path: path to links database
245
+ conn: sqlite3 connector to the link database
246
+ gradiv_directory: output directory path
247
+ id_table_path: path to genotype table with node IDs
248
+ seq_table_path: path to genotype table with node sequences
249
+ stats_path: path to statistics file output
250
+ """
251
+ def __init__(self, gratools_directory:str):
252
+ """
253
+ Initialize a graph index
254
+ :param gratools_directory: Path to GraTools index directory
255
+ """
256
+ self.gratools_directory = Path(gratools_directory).resolve()
257
+ self.pg_name = self.gratools_directory.name.split('_GraTools_INDEX')[0]
258
+ self.samples = [] #TODO: add chromosome ?
259
+
260
+ # BAM related attributes
261
+ self.bam_path = self.gratools_directory / f'bam_files/{self.pg_name}.bam'
262
+ self.bam_file = pysam.AlignmentFile(str(self.bam_path), 'rb', check_sq=False) # intended for query
263
+ self.bam_index = None
264
+ self.bam_file_for_parsing = None
265
+
266
+ # BED related attributes
267
+ self.bed_directory_path = self.gratools_directory / 'bed_files'
268
+ self.bed_files = {}
269
+ for file in self.bed_directory_path.iterdir():
270
+ if file.suffix == '.bed':
271
+ self.bed_files[file.stem] = file
272
+ self.samples.append(file.stem)
273
+ self.samples = sorted(self.samples)
274
+
275
+ # Links DB related attributes
276
+ self.db_path = self.gratools_directory / f'links_{self.pg_name}.db'
277
+ self.conn = sqlite3.connect(str(self.db_path))
278
+
279
+ # Genotype tables related attributes
280
+ self.gradiv_directory = self.gratools_directory.parent / f'{self.pg_name}_gradiv'
281
+ self.id_table_path = self.gradiv_directory / f'{self.pg_name}_id_table.csv'
282
+ self.seq_table_path = self.gradiv_directory / f'{self.pg_name}_seq_table.csv'
283
+ self.stats_path = self.gradiv_directory / f'{self.pg_name}_stats.csv'
284
+
285
+ def make_bam_index_on_reads(self) -> None:
286
+ """
287
+ Index BAM file on segment ID
288
+ :return: None
289
+ """
290
+ self.bam_index = pysam.IndexedReads(self.bam_file)
291
+ self.bam_index.build()
292
+
293
+ def query_segment (self, segment_id:str) -> pysam.AlignedSegment|None:
294
+ """
295
+ Query segment from BAM file on ID
296
+ :param segment_id: Segment ID
297
+ :return: BAM segment or None
298
+ """
299
+ read_iterator = self.bam_index.find(segment_id)
300
+ for read in read_iterator:
301
+ return read
302
+ return None
303
+
304
+ def open_bam_for_parsing(self) -> None:
305
+ """
306
+ Make temporary BAM file to parallelize BAM parsing and node query
307
+ :return: None
308
+ """
309
+ self.bam_file_for_parsing = pysam.AlignmentFile(str(self.bam_path), 'rb', check_sq=False)
310
+
311
+ def close(self):
312
+ """
313
+ Closes connections, files and removes temporary file
314
+ :return:
315
+ """
316
+ self.conn.close()
317
+ self.bam_file.close()
318
+ if self.bam_file_for_parsing :
319
+ self.bam_file_for_parsing.close()
320
+
321
+ def query_links(self, segment_id: str) -> list:
322
+ """
323
+ Fetch links from or to the segment
324
+ :param segment_id: query segment id
325
+ :return: list of rows
326
+ """
327
+ cursor = self.conn.cursor()
328
+ cursor.execute('''
329
+ SELECT * FROM links WHERE seg_id_1 = ? OR seg_id_2 = ?
330
+ ''', (segment_id, segment_id))
331
+ return cursor.fetchall()
332
+
333
+ def query_all_links(self) -> list:
334
+ """
335
+ Fetch all links from the link database (intended for debug)
336
+ :return: list of rows
337
+ """
338
+ cursor = self.conn.cursor()
339
+ cursor.execute('SELECT * FROM links')
340
+ return cursor.fetchall()
341
+
342
+ def make_gradiv_directory(self) -> None:
343
+ """
344
+ Make gradiv directory if it does not exist
345
+ :return: None
346
+ """
347
+ self.gradiv_directory.mkdir(parents=True, exist_ok=True)
348
+
349
+ def get_site_position(self, node_id:str, query:str, chrom:str) -> int:
350
+ """
351
+ Get polymorphism position
352
+ :param node_id: ID (with sense) of node before the polymorphism
353
+ :param query: Query to use as a coordinate reference
354
+ :param chrom: Chromosome
355
+ :return: Position of
356
+ """
357
+ bed_file = BedTool(str(self.bed_files[query]))
358
+ for interval in bed_file :
359
+ if interval.name == node_id and interval.chrom == chrom:
360
+ return interval.stop
361
+ return -1
gradiv/polymorphism.py ADDED
@@ -0,0 +1,49 @@
1
+ import gradiv.pggraph as pggraph
2
+
3
+ class Polymorphism:
4
+ def __init__(self) -> None:
5
+ """
6
+ Class representing one polymorphism
7
+ """
8
+ self.node_per_sample = {}
9
+
10
+ def __str__(self) -> str:
11
+ return str(self.node_per_sample)
12
+
13
+ def add_substitution(self, parent:pggraph.Node, substitution_nodes:set[pggraph.Node], grandchild:pggraph.Node) -> None:
14
+ """
15
+ Add alleles (as node IDs) corresponding to the samples involved in a substitution at this site. Can be used to add the node of an insertion.
16
+ :param parent: Node before the substitution
17
+ :param substitution_nodes: Set of node IDs involved in a substitution at this site
18
+ :param grandchild: Node after the substitution
19
+ :return: None
20
+ """
21
+ for site_node_id in substitution_nodes:
22
+ allele_samples = pggraph.Node.shared_samples(parent, parent.current_nodes[site_node_id], grandchild)
23
+ for sample in allele_samples:
24
+ self.node_per_sample[sample] = parent.current_nodes[site_node_id].get_full_id()
25
+
26
+ def add_deletion(self, parent:pggraph.Node, grandchild:pggraph.Node) -> None:
27
+ """
28
+ Add deletion (*) corresponding to the samples involved in a deletion at this site
29
+ :param parent: Node before the deletion
30
+ :param grandchild: Node after the deletion
31
+ :return: None
32
+ """
33
+ excluded_nodes_id = parent.children.difference({grandchild.get_full_id()})
34
+ excluded_samples = set()
35
+ for en_id in excluded_nodes_id:
36
+ excluded_samples.update(parent.current_nodes[en_id].samples)
37
+ allele_samples = pggraph.Node.shared_samples(parent, grandchild).difference(excluded_samples)
38
+ for sample in allele_samples:
39
+ self.node_per_sample[sample] = "*"
40
+
41
+ def add_undefined_values(self, all_samples:list[str]) -> None:
42
+ """
43
+ Add undefined values (?) to samples not involved in the polymorphism
44
+ :param all_samples: List of all samples in the graph
45
+ :return: None
46
+ """
47
+ for sample in all_samples:
48
+ if sample not in self.node_per_sample.keys():
49
+ self.node_per_sample[sample] = "?"
gradiv/tests.py ADDED
@@ -0,0 +1,17 @@
1
+ import pysam
2
+ from pybedtools import BedTool
3
+ import gradiv.pggraph as pggraph
4
+ import pathlib
5
+
6
+
7
+
8
+ bam_for_linear_parsing = pysam.AlignmentFile('/home/imbert/Bureau/gradiv/test_data/test_graph_GraTools_INDEX/bam_files/test_graph.bam', check_sq=False)
9
+ bam_for_query = pysam.AlignmentFile('/home/imbert/Bureau/gradiv/test_data/test_graph_GraTools_INDEX/bam_files/test_graph.bam', check_sq=False)
10
+ bam_index = pysam.IndexedReads(bam_for_query)
11
+
12
+ for segment in bam_for_linear_parsing:
13
+ print(f'Parsing segment {segment.query_name}')
14
+ print("ok")
15
+
16
+ bam_for_linear_parsing.close()
17
+ bam_for_query.close()