pyham 2.0.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.
pyham/parsers.py ADDED
@@ -0,0 +1,959 @@
1
+ import logging
2
+ from collections import defaultdict
3
+ from xml.etree.ElementTree import XMLParser
4
+ from tqdm.auto import tqdm
5
+
6
+ from . import abstractgene
7
+ from .genome import ExtantGenome
8
+ from .taxonomy import build_taxon_node, Taxonomy
9
+ from . import id_formats
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _label_and_species(node):
15
+ """Return a (label, species_names) pair describing an AbstractGene for conflict reporting."""
16
+ if isinstance(node, abstractgene.HOG):
17
+ species = [g.genome.name for g in node.get_all_descendant_genes()]
18
+ return node.hog_id if node.hog_id is not None else "<no id>", species
19
+ label = node.prot_id or node.gene_id or node.unique_id
20
+ return label, [node.genome.name]
21
+
22
+
23
+ def short_level_name(genome):
24
+ """Name of a genome's level, abbreviated if it was synthesized from many child names."""
25
+ return abstractgene.short_level_name(genome.name)
26
+
27
+
28
+ def _format_one_conflict(conflict, index=None, total=None):
29
+ header = f"Family {conflict.family_id} — {conflict.hog_id} claims level \"{conflict.resolved_level}\" ({conflict.claim_property})"
30
+ if index is not None:
31
+ header = f"[{index}/{total}] {header}"
32
+
33
+ lines = [header]
34
+ for label, species, detail in conflict.offending_members:
35
+ species_str = ", ".join(species)
36
+ if conflict.kind == "same_rank":
37
+ lines.append(f" conflict: {label} ({species_str}) resolves to that SAME level.")
38
+ elif conflict.kind == "inverted":
39
+ lines.append(f" conflict: \"{conflict.resolved_level}\" (claimed level) is itself a descendant of {label} ({species_str}) in the given tree.")
40
+ else: # disjoint
41
+ lines.append(f" conflict: {label} ({species_str}) is not below that level in the given tree.")
42
+ if detail:
43
+ lines.append(f" reason: {detail}")
44
+
45
+ if conflict.sibling_members:
46
+ sibling_str = "; ".join(f"{label} ({', '.join(species)})" for label, species in conflict.sibling_members)
47
+ lines.append(f" also at \"{conflict.resolved_level}\" in this family: {sibling_str}")
48
+
49
+ return "\n".join(lines)
50
+
51
+
52
+ def format_taxonomic_conflicts(conflicts: list, cap: int = 20) -> str:
53
+ """Render a list of :obj:`abstractgene.TaxonomicConflict` records into a human-readable report."""
54
+ if not conflicts:
55
+ return "No taxonomic conflicts."
56
+
57
+ if len(conflicts) == 1:
58
+ return _format_one_conflict(conflicts[0])
59
+
60
+ total = len(conflicts)
61
+ shown = conflicts[:cap]
62
+ blocks = [_format_one_conflict(c, i, total) for i, c in enumerate(shown, start=1)]
63
+ report = "\n\n".join(blocks)
64
+ if total > cap:
65
+ report += f"\n\n... and {total - cap} more conflict(s) not shown here; see the .conflicts attribute for the complete list."
66
+ report += f"\n\n{total} conflicts total." if total != 1 else f"\n\n{total} conflict total."
67
+ return report
68
+
69
+
70
+ class OrthoXMLParser:
71
+
72
+ """
73
+ Custom parser for OrthoXML containing HOGs. It can take a FilterParser object to restrict the information to parse.
74
+
75
+ The parse goes through the whole XML and create on the fly the required Ham objects:
76
+ - In the Xref/header, the parser creates the ExtantGenome and Gene objects.
77
+ - the taxonomy group and its taxon elements are parsed to create the taxonomy tree, if no species tree is provided.
78
+ - In the Groups section, it creates the HOGs with their hierarchy (parent/children links) and their related
79
+ AncestralGenomes.
80
+
81
+
82
+ Attributes:
83
+ ham_object (:obj:`Ham`): Ham object to feed with created objects.
84
+ filterObj (:obj:`FilterParser`, optional): FilterParser object used to restrict the parsed information. Defaults
85
+ to None.
86
+
87
+ extant_gene_map (:obj:`dict`): dictionary of gene unique id mapped to their related :obj:`Gene`.
88
+ external_id_mapper (:obj:`dict`): dictionary of xref id mapped to their list of possible unique ids.
89
+ toplevel_hogs (:obj:`dict`): dictionary of top level hog id mapped to their related :obj:`HOG`.
90
+
91
+ cpt (:obj:`int`): Counter of parsed hogs.
92
+ hog_stack (:obj:`list`): Stack of hogs currently parsed. Reset at each top level hog change.
93
+ current_species (:obj:`ExtantGenome`): Pointer to the current species parsed during xref first step.
94
+ paralog_stack (:obj:`list`): Stack of position in hog stack when paralogy event occured and the related DuplicationNode.
95
+ Reset at each top level hog change.
96
+ in_paralogGroup (:obj:`int`): last position in the stack where a duplication occured.
97
+ skip_this_hog (:obj:`Boolean`): Boolean to skip the current hog or not (used when filtering option is set).
98
+ """
99
+
100
+ def __init__(self, ham_object, taxonomy=None, filterObject=None, id_schema: str = None, with_progress: bool = False,
101
+ fail_fast: bool = False):
102
+
103
+ """
104
+ Args:
105
+ ham_object (:obj:`Ham`): Ham object to feed with created objects.
106
+ filterObject (:obj:`FilterParser`, optional): FilterParser object used to restrict the parsed information.
107
+ with_progress (:bool:, optional): Whether to display a tqdm progress bar whilst parsing. Per-species
108
+ and per-500-HOGs messages are logged at DEBUG level so they don't garble the bar at INFO.
109
+ Defaults to None.
110
+ fail_fast (:bool:, optional): If True, raise :obj:`abstractgene.TaxonomicConflictError` immediately at the
111
+ first taxonomic conflict found. If False (default), keep parsing and collect every conflict found across
112
+ the whole file, raising a single combined error once parsing completes.
113
+ id_schema (:obj:`str`, optional): the HOG id scheme used by the orthoxml file (one of
114
+ :obj:`pyham.id_formats.SCHEMES`), used to synthesize ids for HOG levels inserted because the
115
+ species tree implies them but the file doesn't. Defaults to :obj:`pyham.id_formats.GENERIC`
116
+ (no synthesis) if not given.
117
+ """
118
+ self.ham_object = ham_object
119
+ self.filterObj = filterObject
120
+ self.taxonomy = ham_object.taxonomy
121
+ self._id_schema = id_schema if id_schema is not None else id_formats.GENERIC
122
+
123
+ # usefull information
124
+ self.extant_gene_map = {}
125
+ self.external_id_mapper = defaultdict(list)
126
+ self.toplevel_hogs = {}
127
+
128
+ # On the fly variable
129
+ self.cpt = 0
130
+ self.hog_stack = []
131
+ self.paralog_stack = []
132
+ self.taxon_stack = []
133
+ self.current_species = None
134
+ self.in_paralogGroup = None
135
+ self.paralogyNode = None
136
+ self.skip_this_hog = False
137
+ self._genomes_to_add = []
138
+
139
+ # id of the top-level family being parsed (None between families), used to give errors context
140
+ self.current_family_id = None
141
+ self._family_index = 0
142
+ # OrthoXML species that couldn't be mapped onto the species tree, as (name, reason) pairs;
143
+ # collected over the whole <species> section and reported together before <groups>
144
+ self.unmapped_species = []
145
+ self.n_species = 0
146
+ # ids of HOGs collapsed into their parent because they sat at the same taxonomic level
147
+ self.collapsed_same_level_hogs = []
148
+
149
+ # taxonomic conflict tracking
150
+ self.fail_fast = fail_fast
151
+ self.taxonomic_conflicts = []
152
+ self._conflicted_children = set()
153
+
154
+ # counter for freshly-minted duplication-branch indices (see
155
+ # _assign_duplication_branch_ids) -- seeded far above any realistic real index so
156
+ # it can never collide with one; only needs to be distinct from other
157
+ # freshly-minted indices, which a monotonic counter guarantees.
158
+ self._next_synthetic_dup_idx = 10 ** 4
159
+
160
+ # save progress bar option
161
+ self.with_progress = with_progress
162
+
163
+ def _build_gene(self, attrib):
164
+ gene = abstractgene.Gene(**attrib)
165
+ self.current_species.add_gene(gene)
166
+ self.extant_gene_map[gene.unique_id] = gene
167
+ for type, Id in attrib.items():
168
+ if type != "id":
169
+ self.external_id_mapper.setdefault(Id, []).append(gene.unique_id)
170
+
171
+ def _build_hog(self, attrib):
172
+ hog = abstractgene.HOG(arose_by_duplication=False, **attrib)
173
+
174
+ if self.in_paralogGroup == len(self.hog_stack):
175
+ self.paralogyNode.add_child(hog)
176
+
177
+ if len(self.hog_stack) > 0:
178
+ self.hog_stack[-1].add_child(hog)
179
+ self.hog_stack.append(hog)
180
+
181
+ def _update_progress_bar(self, tag):
182
+ if tag == "{http://orthoXML.org/2011/}species":
183
+ if not hasattr(self, 'sp_pbar'):
184
+ self.sp_pbar = tqdm(desc='Parsing Species')
185
+ self.sp_pbar.update()
186
+ elif tag == "{http://orthoXML.org/2011/}orthologGroup":
187
+ if not hasattr(self, 'hog_pbar'):
188
+ if hasattr(self, 'sp_pbar'):
189
+ self.sp_pbar.close()
190
+ logger.info("Parsing Species completed.")
191
+ delattr(self, 'sp_pbar')
192
+ self.hog_pbar = tqdm(desc='Parsing HOGs')
193
+ self.hog_pbar.update()
194
+ elif tag == "{http://orthoXML.org/2011/}groups":
195
+ if hasattr(self, 'hog_pbar'):
196
+ self.hog_pbar.close()
197
+ delattr(self, 'hog_pbar')
198
+ logger.info("Parsing completed.")
199
+
200
+ def close_progress_bars(self):
201
+ """Close any progress bar left open, e.g. when parsing aborted before reaching </groups>."""
202
+ for attr in ('sp_pbar', 'hog_pbar'):
203
+ if hasattr(self, attr):
204
+ getattr(self, attr).close()
205
+ delattr(self, attr)
206
+
207
+ def _add_extant_genome_to_taxonomy(self, genome):
208
+ try:
209
+ taxon = self.taxonomy.get_extant_taxa_by_name(genome.name)
210
+ except KeyError as e:
211
+ self.unmapped_species.append((genome.name, e.args[0] if e.args else str(e)))
212
+ return
213
+ self.taxonomy.add_genome_to_node(taxon, genome)
214
+
215
+ def _assign_ancestral_genome(self, hog):
216
+ """This method determines the ancestral genome for a HOG based on its children."""
217
+ child_genomes = [child.genome for child in hog.children]
218
+ tax_node = None
219
+ if hog.taxon_id is not None:
220
+ # If the HOG has a taxon_id, we can directly assign the ancestral genome based on the taxon.
221
+ tax_nodes = list(self.taxonomy.nodes_by_attr(id=hog.taxon_id))
222
+ if len(tax_nodes) == 1:
223
+ tax_node = tax_nodes[0]
224
+ if len(set(child_genomes)) == 1 and child_genomes[0].taxon == tax_node:
225
+ raise abstractgene.SameLevelHOGError(f"HOG '{hog.hog_id}' at {tax_node.name} has child at same level: {hog.children[0]}.")
226
+ # let's check if we can map it using the TaxRange or taxid property
227
+ if tax_node is None:
228
+ for key in ('TaxRange', 'taxid'):
229
+ try:
230
+ tax_value = hog[key]
231
+ tax_node = self.taxonomy.get_node_by_name(tax_value)
232
+ except (KeyError, AttributeError):
233
+ pass
234
+ else:
235
+ failing = [(child, child.genome) for child in hog.children
236
+ if not self.taxonomy.is_child_recursive(child.genome.taxon, tax_node)]
237
+ if not failing:
238
+ break
239
+
240
+ if len(set(child_genomes)) == 1 and failing[0][1].taxon == tax_node:
241
+ raise abstractgene.SameLevelHOGError(f"HOG '{hog.hog_id}' at {tax_node.name} has child at same level: {failing[0][1]}.")
242
+
243
+ for conflict in self._build_taxonomic_conflicts(hog, key, tax_value, tax_node, failing):
244
+ if self.fail_fast:
245
+ raise abstractgene.TaxonomicConflictError(format_taxonomic_conflicts([conflict]), [conflict])
246
+ self.taxonomic_conflicts.append(conflict)
247
+ for child, _ in failing:
248
+ self._conflicted_children.add(child)
249
+ break
250
+
251
+ # if not, let's try with the children genomes
252
+ if tax_node is None:
253
+ if len(set(child_genomes)) == 1:
254
+ try:
255
+ tax_range = hog['TaxRange']
256
+ if hog['TaxRange'] == hog.children[0].genome.name:
257
+ raise abstractgene.SameLevelHOGError(f"HOG '{hog.hog_id}' at {tax_range} has child at same level: {hog.children[0]}.")
258
+ except (KeyError, AttributeError):
259
+ pass
260
+ tax_node = child_genomes[0].taxon.up
261
+ else:
262
+ tax_node = self.taxonomy.get_mrca_taxnode(*(g.taxon for g in child_genomes))
263
+ genome = self.taxonomy.get_genome_from_taxnode(tax_node)
264
+ genome.add_gene(hog)
265
+ logger.debug(f"Added {hog} to ancestral genome {genome.name}")
266
+
267
+ def _classify_conflict(self, child, child_node, tax_node):
268
+ """Classify why `child_node` doesn't sit below `tax_node`, and build a human-readable reason.
269
+
270
+ Returns a (kind, detail) pair where kind is one of "same_rank" (child collapses onto the
271
+ same node as `tax_node`), "inverted" (tax_node is itself a descendant of child_node), or
272
+ "disjoint" (the two sit in unrelated branches of the given tree).
273
+ """
274
+ if child_node == tax_node:
275
+ own_claim_prop, own_claim_val = None, None
276
+ for key in ('TaxRange', 'taxid'):
277
+ try:
278
+ own_claim_val = child[key]
279
+ own_claim_prop = key
280
+ break
281
+ except (KeyError, AttributeError):
282
+ continue
283
+ if own_claim_val is not None:
284
+ detail = (f"its own claimed {own_claim_prop}=\"{own_claim_val}\" is not a taxon in the given tree; "
285
+ f"the common ancestor of its members there is \"{tax_node.name}\" itself.")
286
+ else:
287
+ detail = f"the common ancestor of its members there is \"{tax_node.name}\" itself, the same level as its own parent."
288
+ return "same_rank", detail
289
+
290
+ if self.taxonomy.is_child_recursive(tax_node, child_node):
291
+ return "inverted", None
292
+
293
+ common = self.taxonomy.get_mrca_taxnode(child_node, tax_node)
294
+ intermediate = [n.name for n in reversed(self.taxonomy.get_path_up(child_node, common))]
295
+ chain = " > ".join([common.name] + intermediate + [child_node.name])
296
+ return "disjoint", f"its lineage there runs {chain}."
297
+
298
+ def _build_taxonomic_conflicts(self, hog, claim_property, claim_value, tax_node, failing):
299
+ """Build one :obj:`abstractgene.TaxonomicConflict` per distinct conflict kind found among
300
+ `hog`'s failing children (in the overwhelmingly common case, all failing children share one
301
+ kind and this returns a single-element list)."""
302
+ failing_children = {child for child, _ in failing}
303
+ sibling_members = [_label_and_species(c) for c in hog.children if c not in failing_children]
304
+
305
+ by_kind = defaultdict(list)
306
+ for child, child_genome in failing:
307
+ kind, detail = self._classify_conflict(child, child_genome.taxon, tax_node)
308
+ label, species = _label_and_species(child)
309
+ by_kind[kind].append((label, species, detail))
310
+
311
+ family_id = (self.hog_stack[0].hog_id if self.hog_stack else hog.hog_id).split('_')[0]
312
+ return [
313
+ abstractgene.TaxonomicConflict(
314
+ family_id=family_id,
315
+ hog_id=hog.hog_id,
316
+ claim_property=claim_property,
317
+ claim_value=str(claim_value),
318
+ resolved_level=tax_node.name,
319
+ kind=kind,
320
+ offending_members=members,
321
+ sibling_members=sibling_members,
322
+ )
323
+ for kind, members in by_kind.items()
324
+ ]
325
+
326
+ def _create_missing_hogs(self, child: abstractgene.AbstractGene, parent: abstractgene.HOG):
327
+ """Create missing HOGs between a child and a parent genome.
328
+ Returns the oldest created HOG, or the child if no HOG was created."""
329
+ if child == parent:
330
+ raise ValueError("Child and parent genomes are the same, cannot create missing HOGs.")
331
+
332
+ missing_taxons = self.taxonomy.get_path_up(child.genome.taxon, parent.genome.taxon)
333
+ if len(missing_taxons) > 0:
334
+ # the youngest hog is removed from the oldest hog's children.
335
+ parent.remove_child(child)
336
+ # Then for each intermediate level in between the two hogs...
337
+ current_child = child
338
+ anchor_id = child.hog_id if hasattr(child, 'hog_id') else parent.hog_id
339
+ for tax in missing_taxons:
340
+ # ... we get the related ancestral genome of this level...
341
+ ancestral_genome = self.taxonomy.get_genome_from_taxnode(tax)
342
+
343
+ # ... we create the related hog and add it to the ancestral genome...
344
+ hog_id = id_formats.make_missing_hog_id(self._id_schema, anchor_id, ancestral_genome.taxon)
345
+ hog = abstractgene.HOG(id=hog_id)
346
+ setattr(hog, '_missing_in_xml', parent.genome)
347
+ ancestral_genome.add_gene(hog)
348
+
349
+ # ... we check if taxon correspond to child parent taxon ...
350
+ if ancestral_genome.taxon is not current_child.genome.taxon.up:
351
+ raise TypeError(
352
+ "HOG taxon {} is different than child parent taxon {}".format(ancestral_genome.taxon,
353
+ current_child.genome.taxon.up))
354
+ # ... we add the child if everything is fine.
355
+ hog.add_child(current_child)
356
+ current_child = hog
357
+ parent.add_child(current_child)
358
+ logger.debug(f"Created {len(missing_taxons)} missing HOGs between {child} and {parent}.")
359
+ return current_child if len(missing_taxons) > 0 else child
360
+
361
+ def _describe_child_not_below_parent(self, parent, child):
362
+ def describe(node):
363
+ label, species = _label_and_species(node)
364
+ species = sorted(set(species))
365
+ shown = ", ".join(species[:5]) + (f", ... ({len(species)} species)" if len(species) > 5 else "")
366
+ return f"{label} ({shown})"
367
+
368
+ others = "; ".join(describe(c) for c in parent.children if c is not child)
369
+ where = "the same level as" if child.genome.taxon == parent.genome.taxon else "a level above"
370
+ return (f"Group {describe(child)} resolves to \"{short_level_name(child.genome)}\", {where} its parent group "
371
+ f"{_label_and_species(parent)[0]}, but a nested group must sit below its parent. The parent also "
372
+ f"contains {others or 'nothing else'} as a separate lineage, which the species tree doesn't allow. "
373
+ f"The group nesting contradicts the species tree, e.g. because it comes from a gene tree that "
374
+ f"wasn't reconciled with this species tree.")
375
+
376
+ def _resolve_missing_levels(self, parent: abstractgene.HOG, children: list):
377
+ """Insert missing intermediate HOG levels between `parent` and `children`.
378
+
379
+ If several children are missing the same intermediate taxon (i.e. the
380
+ reference species tree resolves this part of the taxonomy more finely
381
+ than the HOGs were built/augmented at), a single shared ancestral HOG is
382
+ created for all of them instead of one redundant HOG per child. Children
383
+ that are missing no shared level are delegated to `_create_missing_hogs`.
384
+
385
+ Children already flagged as a taxonomic conflict are skipped entirely:
386
+ their genome doesn't actually sit below `parent` in the given tree, so
387
+ there is no valid path to insert missing levels along.
388
+ """
389
+ children = [c for c in children if c not in self._conflicted_children]
390
+ change = {}
391
+ for child in children:
392
+ if parent.genome.taxon.props["depth"] != child.genome.taxon.props["depth"] - 1:
393
+ if not self.taxonomy.is_child_recursive(child.genome.taxon, parent.genome.taxon):
394
+ raise abstractgene.EvolutionaryConceptError(self._describe_child_not_below_parent(parent, child))
395
+ change[child] = self.taxonomy.get_path_up(child.genome.taxon, parent.genome.taxon)
396
+ if not change:
397
+ return
398
+
399
+ groups = defaultdict(list)
400
+ for child, missing in change.items():
401
+ # missing[-1] is the taxon immediately below `parent`; children sharing
402
+ # any ancestor on the path back to `parent` necessarily share this one too.
403
+ groups[missing[-1]].append(child)
404
+
405
+ for tax_node, group_children in groups.items():
406
+ if len(group_children) == 1:
407
+ self._create_missing_hogs(group_children[0], parent)
408
+ else:
409
+ shared_genome = self.taxonomy.get_genome_from_taxnode(tax_node)
410
+ shared_hog_id = id_formats.make_missing_hog_id(self._id_schema, parent.hog_id, tax_node)
411
+ shared_hog = abstractgene.HOG(id=shared_hog_id)
412
+ setattr(shared_hog, "_missing_in_xml", parent.genome)
413
+ shared_genome.add_gene(shared_hog)
414
+ parent.add_child(shared_hog)
415
+ for child in group_children:
416
+ parent.remove_child(child)
417
+ shared_hog.add_child(child)
418
+ self._resolve_missing_levels(shared_hog, group_children)
419
+
420
+ def _assign_duplication_branch_ids(self, parent_hog: abstractgene.HOG, duplication, children: list):
421
+ """Give every duplication branch in `children` (direct children of `parent_hog`,
422
+ all flagged as arising from `duplication`) its own distinguishing LOFT dot-chain
423
+ segment when it lacks a real id of its own -- instead of every id-less sibling
424
+ falling back to `parent_hog`'s own id unchanged, which collapses distinct paralog
425
+ lineages onto the same synthesized id (e.g. two independent post-duplication
426
+ genes both becoming "HOG:X.3a_71" instead of "HOG:X.3a.1a_71"/"HOG:X.3a.1b_71").
427
+
428
+ Real ids already present on some siblings are never touched, and their
429
+ (idx, letter) are reused/avoided when assigning the missing ones. If none of
430
+ `children` reveal a real index at all, a fresh one is minted from a counter that
431
+ only needs to be distinct from other freshly-minted indices (any synthesized id
432
+ is already uniquely prefixed by `parent_hog`'s own -- already unique -- id).
433
+
434
+ Returns the subset of `children` that got moved under a freshly-created wrapper
435
+ HOG, so the caller can exclude them from any further, generic gap-filling over
436
+ `parent_hog`'s direct children (they're no longer among them).
437
+ """
438
+ if self._id_schema not in (id_formats.LOFT, id_formats.LOFT_TAXID):
439
+ return set()
440
+
441
+ needs_assignment = [c for c in children if getattr(c, 'hog_id', None) is None]
442
+ if not needs_assignment:
443
+ return set()
444
+
445
+ m = id_formats.HOG_ID_RE.match(parent_hog.hog_id) if parent_hog.hog_id else None
446
+ if not m:
447
+ return set()
448
+ prefix = m.group('fam') + m.group('subhog')
449
+
450
+ real_segments = [seg for seg in
451
+ (id_formats.parse_branch_segment(getattr(c, 'hog_id', None), prefix) for c in children)
452
+ if seg is not None]
453
+ if real_segments:
454
+ idx = real_segments[0][0]
455
+ used_numbers = {id_formats.decode_paralog_branch(letters)
456
+ for seg_idx, letters in real_segments if seg_idx == idx}
457
+ else:
458
+ idx = self._next_synthetic_dup_idx
459
+ self._next_synthetic_dup_idx += 1
460
+ used_numbers = set()
461
+
462
+ use_taxid = self._id_schema == id_formats.LOFT_TAXID
463
+ wrapped = set()
464
+ next_number = 0
465
+ for child in needs_assignment:
466
+ while next_number in used_numbers:
467
+ next_number += 1
468
+ used_numbers.add(next_number)
469
+ branch_chain = f".{idx}{id_formats.encode_paralog_branch(next_number)}"
470
+
471
+ if isinstance(child, abstractgene.HOG):
472
+ # a real orthologGroup element with no id attribute: relabel in place.
473
+ taxid = id_formats.taxid_for_taxon(child.genome.taxon) if use_taxid else None
474
+ child.hog_id = f"{prefix}{branch_chain}_{taxid}" if taxid is not None else f"{prefix}{branch_chain}"
475
+ continue
476
+
477
+ # bare gene: only worth a wrapper HOG if there's real vertical distance
478
+ # between the duplication and its species -- a real LOFT="..." geneRef
479
+ # attribute never carries a taxid either, so a wrapper-less label matches
480
+ # that same convention.
481
+ missing_taxons = self.taxonomy.get_path_up(child.genome.taxon, parent_hog.genome.taxon)
482
+ if not missing_taxons:
483
+ child.set_LOFT(branch_chain)
484
+ continue
485
+
486
+ branch_taxon = missing_taxons[-1]
487
+ ancestral_genome = self.taxonomy.get_genome_from_taxnode(branch_taxon)
488
+ taxid = id_formats.taxid_for_taxon(branch_taxon) if use_taxid else None
489
+ branch_id = f"{prefix}{branch_chain}_{taxid}" if taxid is not None else f"{prefix}{branch_chain}"
490
+ wrapper = abstractgene.HOG(id=branch_id)
491
+ setattr(wrapper, "_missing_in_xml", parent_hog.genome)
492
+ ancestral_genome.add_gene(wrapper)
493
+
494
+ parent_hog.remove_child(child)
495
+ wrapper.add_child(child)
496
+ parent_hog.add_child(wrapper)
497
+ self._create_missing_hogs(child, wrapper)
498
+
499
+ duplication.remove_child(child)
500
+ duplication.add_child(wrapper)
501
+ wrapped.add(child)
502
+
503
+ return wrapped
504
+
505
+ def start(self, tag, attrib):
506
+ if tag == "{http://orthoXML.org/2011/}species":
507
+ self.current_species = ExtantGenome(**attrib)
508
+ self.n_species += 1
509
+ if self.taxonomy is not None:
510
+ self._add_extant_genome_to_taxonomy(self.current_species)
511
+ else:
512
+ self._genomes_to_add.append(self.current_species)
513
+
514
+ elif tag == "{http://orthoXML.org/2011/}gene" and (self.filterObj is None or attrib["id"] in self.filterObj.geneUniqueId):
515
+ self._build_gene(attrib)
516
+
517
+ elif tag == "{http://orthoXML.org/2011/}paralogGroup" and self.skip_this_hog is False:
518
+ cur_depth = len(self.hog_stack)
519
+ if len(self.paralog_stack) > 0 and self.paralog_stack[-1]['depth'] == cur_depth:
520
+ # we have a directly nested paralog group. use the same DuplicationNode
521
+ dNode = self.paralog_stack[-1]['node']
522
+ else:
523
+ dNode = abstractgene.DuplicationNode(self, id=attrib.get('og', None))
524
+
525
+ self.paralog_stack.append({'depth': cur_depth, 'node': dNode})
526
+
527
+ self.in_paralogGroup = self.paralog_stack[-1]['depth']
528
+ self.paralogyNode = self.paralog_stack[-1]['node']
529
+
530
+ elif tag == "{http://orthoXML.org/2011/}geneRef" and self.skip_this_hog is False:
531
+
532
+ gene = self.extant_gene_map[attrib['id']]
533
+ if 'LOFT' in attrib:
534
+ gene.set_LOFT(attrib['LOFT'])
535
+ self.hog_stack[-1].add_child(gene)
536
+
537
+ # if the gene is contained within a paralogousGroup need to update its .arose_by_duplication flag.
538
+ if self.in_paralogGroup == len(self.hog_stack):
539
+ self.paralogyNode.add_child(gene)
540
+
541
+ elif tag == "{http://orthoXML.org/2011/}orthologGroup":
542
+ if len(self.hog_stack) == 0:
543
+ # a new roothog
544
+ self._family_index += 1
545
+ self.current_family_id = attrib.get("id") or "#{} (without id)".format(self._family_index)
546
+ if self.with_progress:
547
+ self._update_progress_bar(tag)
548
+
549
+ if self.filterObj is not None and not attrib.get("id", None) in self.filterObj.hogsId:
550
+ self.skip_this_hog = True
551
+ # create HOG and add it to the stack (or a dummy value if we skip this hog)
552
+ if self.skip_this_hog:
553
+ self.hog_stack.append(0)
554
+ else:
555
+ self._build_hog(attrib)
556
+
557
+ elif tag == "{http://orthoXML.org/2011/}property" and not self.skip_this_hog:
558
+ self.hog_stack[-1].add_property(attrib["name"], attrib["value"])
559
+
560
+ elif tag == "{http://orthoXML.org/2011/}score" and not self.skip_this_hog:
561
+ self.hog_stack[-1].score(attrib['id'], float(attrib['value']))
562
+
563
+ elif tag == "{http://orthoXML.org/2011/}taxon":
564
+ taxon = build_taxon_node(**attrib)
565
+ self.taxon_stack.append(taxon)
566
+ if len(self.taxon_stack) > 1:
567
+ self.taxon_stack[-2].add_child(child=taxon)
568
+
569
+ elif tag == "{http://orthoXML.org/2011/}groups":
570
+ if self.taxonomy is None:
571
+ raise ValueError("Cannot parse this OrthoXML file without an explicit taxonomy tree. Please provide one.")
572
+ if self.unmapped_species:
573
+ from .validation import species_not_in_tree_error
574
+ raise species_not_in_tree_error(self.unmapped_species, self.n_species, self.taxonomy)
575
+
576
+ def end(self, tag):
577
+
578
+ if tag == "{http://orthoXML.org/2011/}species":
579
+ if self.with_progress:
580
+ self._update_progress_bar(tag)
581
+ logger.debug(f"Species {self.current_species.name} created.")
582
+ self.current_species = None
583
+
584
+ elif tag == "{http://orthoXML.org/2011/}taxon":
585
+ taxonomy = self.taxon_stack.pop()
586
+ if len(self.taxon_stack) == 0:
587
+ # we are at the root of the taxonomy tree. if no taxonomy is provided upfront, we create it.
588
+ if self.taxonomy is None:
589
+ self.taxonomy = Taxonomy(taxonomy)
590
+ # add all extant genomes to the taxonomy tree
591
+ for genome in self._genomes_to_add:
592
+ self._add_extant_genome_to_taxonomy(genome)
593
+ logger.info("Taxonomy tree created from OrthoXML taxonomy. All extant genomes added to the tree.")
594
+ else:
595
+ logger.info("OrthoXML taxonomy not used, using provided taxonomy tree instead.")
596
+
597
+ elif tag == "{http://orthoXML.org/2011/}paralogGroup" and self.skip_this_hog is False:
598
+ ln = self.paralog_stack.pop()
599
+ ln['node'].set_MRCA()
600
+ if len(self.paralog_stack) > 0:
601
+ self.in_paralogGroup = self.paralog_stack[-1]['depth']
602
+ self.paralogyNode = self.paralog_stack[-1]['node']
603
+ else:
604
+ self.in_paralogGroup = None
605
+ self.paralogyNode = None
606
+
607
+ elif tag == "{http://orthoXML.org/2011/}orthologGroup":
608
+ # get the latest hog
609
+ hog = self.hog_stack.pop()
610
+ if self.skip_this_hog:
611
+ if len(self.hog_stack) == 0:
612
+ self.skip_this_hog = False
613
+ self.current_family_id = None
614
+ return
615
+
616
+ try:
617
+ # assign the ancestral genome to this hog
618
+ self._assign_ancestral_genome(hog)
619
+ except abstractgene.SameLevelHOGError as e:
620
+ # this hog has children at the same level as its parent. Usually an 'augmented' HOG.
621
+ logger.info(str(e))
622
+ self.collapsed_same_level_hogs.append(hog.hog_id)
623
+ # remove this hog structure.
624
+ for child in hog.children:
625
+ child.parent = hog.parent
626
+ hog.parent.children.remove(hog)
627
+ hog.parent.children.extend(hog.children)
628
+ if hog.arose_by_duplication:
629
+ dupl_node = hog.arose_by_duplication
630
+ dupl_node.remove_child(hog)
631
+ for child in hog.children:
632
+ dupl_node.add_child(child)
633
+ return
634
+
635
+ # get all child clustered by dup if any (skip children already flagged as a taxonomic
636
+ # conflict -- their genome doesn't reliably sit below hog.genome in the given tree, so
637
+ # the MRCA-below-hog assertion below can't be trusted for them)
638
+ child_by_duplication = defaultdict(list)
639
+ for child in hog.children:
640
+ if child in self._conflicted_children:
641
+ continue
642
+ if child.arose_by_duplication:
643
+ child_by_duplication[child.arose_by_duplication].append(child)
644
+
645
+ # all duplications must have their MRCA equal to hog.genome or below
646
+ for duplication, children in child_by_duplication.items():
647
+ if not (duplication.MRCA == hog.genome or self.taxonomy.is_child_recursive(duplication.MRCA.taxon, hog.genome.taxon)):
648
+ copies = ", ".join(f"{_label_and_species(c)[0]} at \"{short_level_name(c.genome)}\""
649
+ for c in children)
650
+ raise abstractgene.EvolutionaryConceptError(
651
+ f"HOG {_label_and_species(hog)[0]} at \"{short_level_name(hog.genome)}\" contains a "
652
+ f"duplication whose copies ({copies}) split before \"{short_level_name(duplication.MRCA)}\", "
653
+ f"above the level of the HOG itself, but a HOG can't contain copies of a duplication older "
654
+ f"than itself. Usually the HOG's level was inferred from its member species because its "
655
+ f"TaxRange/taxid isn't a node of the species tree, or the group nesting contradicts the tree.")
656
+
657
+
658
+ # For each duplication
659
+ for duplication, children in child_by_duplication.items():
660
+
661
+ # add hog at the level of the duplication MRCA if its missing
662
+ if duplication.MRCA != hog.genome and self.taxonomy.is_child_recursive(duplication.MRCA.taxon, hog.genome.taxon):
663
+ # create the MRCA hog
664
+ mrca_genome = duplication.MRCA
665
+ mrca_hog_id = id_formats.make_missing_hog_id(self._id_schema, hog.hog_id, mrca_genome.taxon)
666
+ mrca_hog = abstractgene.HOG(id=mrca_hog_id)
667
+ setattr(mrca_hog, "_missing_in_xml", hog.genome)
668
+ mrca_genome.add_gene(mrca_hog)
669
+
670
+ # link it to the current hog as parent
671
+ hog.add_child(mrca_hog)
672
+
673
+ # add missing level down (from mrcaHOG to duplicated child)
674
+ for child in children:
675
+ # move child from hog to mrca_hog
676
+ hog.remove_child(child)
677
+ mrca_hog.add_child(child)
678
+
679
+ # give any duplication branch lacking its own real id a proper,
680
+ # distinguishing LOFT segment before filling any remaining gaps
681
+ wrapped = self._assign_duplication_branch_ids(mrca_hog, duplication, children)
682
+ remaining = [c for c in children if c not in wrapped]
683
+
684
+ # add missing levels if any, grouping children that are
685
+ # missing the same intermediate ancestor under one shared HOG
686
+ self._resolve_missing_levels(mrca_hog, remaining)
687
+
688
+ duplication.set_parent(mrca_hog)
689
+ for x in list(duplication.children):
690
+ duplication.remove_child(x)
691
+ for y in list(mrca_hog.children):
692
+ duplication.add_child(y)
693
+
694
+ # Otherwise simply add missing taxa between hog and duplicated child
695
+ else:
696
+ duplication.MRCA = self.taxonomy.get_genome_from_taxnode(hog.genome.taxon)
697
+ duplication.set_parent(hog)
698
+
699
+ # give any duplication branch lacking its own real id a proper,
700
+ # distinguishing LOFT segment before filling any remaining gaps
701
+ wrapped = self._assign_duplication_branch_ids(hog, duplication, children)
702
+
703
+ for child_direct in children:
704
+ if child_direct in wrapped:
705
+ continue
706
+ new_direct_child = self._create_missing_hogs(child_direct, hog)
707
+ duplication.remove_child(child_direct)
708
+ duplication.add_child(new_direct_child)
709
+
710
+ # insert any remaining missing intermediate levels, grouping children
711
+ # that are missing the same intermediate ancestor under one shared HOG
712
+ self._resolve_missing_levels(hog, list(hog.children))
713
+
714
+ if len(self.hog_stack) == 0:
715
+ hog_id = hog.hog_id.split('_')[0]
716
+ self.toplevel_hogs[hog_id] = hog
717
+ self.current_family_id = None
718
+ self.cpt += 1
719
+ if self.cpt % 500 == 0:
720
+ logger.debug("{} HOGs parsed. ".format(self.cpt))
721
+
722
+ elif tag == "{http://orthoXML.org/2011/}groups":
723
+ if self.with_progress:
724
+ self._update_progress_bar(tag)
725
+
726
+ def data(self, data):
727
+ # Ignore data inside nodes
728
+ pass
729
+
730
+ def close(self):
731
+ # Nothing special to do here
732
+ return
733
+
734
+
735
+ class IDSchemeSniffer:
736
+ """Lightweight OrthoXML parser target used to sample HOG ids for id-scheme
737
+ auto-detection (see :obj:`pyham.id_formats.detect_id_scheme`), without paying for a
738
+ full parse of the file.
739
+
740
+ Collects the `id` attribute of every `orthologGroup` element seen, and flips `done`
741
+ to True once a bounded number of top-level families or ids has been sampled -- the
742
+ caller is expected to stop feeding lines once `done` is True.
743
+ """
744
+
745
+ MAX_FAMILIES = 3
746
+ MAX_IDS = 300
747
+ _TAG = "{http://orthoXML.org/2011/}orthologGroup"
748
+
749
+ def __init__(self):
750
+ self.samples = []
751
+ self.families_seen = 0
752
+ self._depth = 0
753
+ self.done = False
754
+
755
+ def start(self, tag, attrib):
756
+ if tag == self._TAG:
757
+ self._depth += 1
758
+ if 'id' in attrib:
759
+ self.samples.append(attrib['id'])
760
+ if len(self.samples) >= self.MAX_IDS:
761
+ self.done = True
762
+
763
+ def end(self, tag):
764
+ if tag == self._TAG:
765
+ self._depth -= 1
766
+ if self._depth == 0:
767
+ self.families_seen += 1
768
+ if self.families_seen >= self.MAX_FAMILIES:
769
+ self.done = True
770
+
771
+ def data(self, data):
772
+ pass
773
+
774
+ def close(self):
775
+ return
776
+
777
+
778
+ def sniff_id_schema(file_object):
779
+ """Sample the first few families of an OrthoXML file object and return the detected HOG id scheme
780
+ (see :obj:`pyham.id_formats.detect_id_scheme`)."""
781
+ sniffer = IDSchemeSniffer()
782
+ sniff_parser = XMLParser(target=sniffer)
783
+ # <species>/<taxonomy> can dwarf <groups> in size (e.g. for large exports with
784
+ # a big embedded taxonomy); skip straight to <groups> via a cheap text search
785
+ # instead of XML-parsing that whole preamble just to sample a few HOG ids.
786
+ sniff_parser.feed('<orthoXML xmlns="http://orthoXML.org/2011/">')
787
+ seen_groups = False
788
+ for line in file_object:
789
+ if not seen_groups:
790
+ idx = line.find('<groups')
791
+ if idx == -1:
792
+ continue
793
+ line = line[idx:]
794
+ seen_groups = True
795
+ sniff_parser.feed(line)
796
+ if sniffer.done:
797
+ break
798
+ return id_formats.detect_id_scheme(sniffer.samples)
799
+
800
+
801
+ class FilterOrthoXMLParser(object):
802
+ """
803
+ Custom OrthoXML parser use to read the orthoxml file and get required information base on specific query set. It use
804
+ the ParserFilter object as input to do the selection.
805
+
806
+ The parse goes through the whole XML and create on the fly the required Ham objects:
807
+ - In the Xref/header, the parser creates the ExtantGenome and Gene objects.
808
+ - In the Groups section, it creates the HOGs with their hierarchy (parent/children links) and their related
809
+ AncestralGenomes.
810
+
811
+
812
+ Attributes:
813
+ filterObj (:obj:`FilterParser`): Filter object with all the filtering information.
814
+
815
+ geneUniqueId (:obj:`list`): list of unique gene ids to store for the OrthoXMLParser.
816
+ hogsId (:obj:`list`): list of hogs ids to store for the OrthoXMLParser.
817
+
818
+ current_hog (:obj:`int`): Current hog id.
819
+ hog_stack (:obj:`list`): Stack of hogs currently parsed. Reset at each top level hog change.
820
+ hog_generef (:obj:`list`): list of unique gene ids contained into the current parsed hog.
821
+ add_this_hog (:obj:`Boolean`): Boolean to know if we keep the current hog for the OrthoXMLParser.
822
+ """
823
+
824
+ def __init__(self, filterO):
825
+
826
+ self.filterObj = filterO
827
+
828
+ self.geneUniqueId = []
829
+ self.hogsId = []
830
+
831
+ self.current_hog = None
832
+ self.hog_stack = []
833
+ self.hog_generef = []
834
+ self.add_this_hog = False
835
+
836
+ def start(self, tag, attrib):
837
+
838
+ if tag == "{http://orthoXML.org/2011/}gene":
839
+ if self.filterObj.GeneIntId_filter:
840
+ if attrib['id'] in self.filterObj.GeneIntId_filter:
841
+ self.geneUniqueId.append(attrib['id'])
842
+
843
+ if self.filterObj.GeneExtId_filter:
844
+ for xtid in attrib.values():
845
+ if xtid in self.filterObj.GeneExtId_filter:
846
+ self.geneUniqueId.append(attrib['id'])
847
+
848
+ elif tag == "{http://orthoXML.org/2011/}geneRef":
849
+ self.hog_generef.append(attrib['id'])
850
+ if attrib['id'] in self.geneUniqueId:
851
+ self.add_this_hog = True
852
+
853
+ elif tag == "{http://orthoXML.org/2011/}orthologGroup":
854
+ if len(self.hog_stack) == 0:
855
+ self.current_hog = attrib["id"]
856
+ if self.current_hog in self.filterObj.HOGId_filter:
857
+ self.add_this_hog = True
858
+
859
+ self.hog_stack.append(1)
860
+
861
+ def end(self, tag):
862
+
863
+ if tag == "{http://orthoXML.org/2011/}orthologGroup":
864
+
865
+ hog = self.hog_stack.pop()
866
+
867
+ if len(self.hog_stack) == 0:
868
+
869
+ if self.add_this_hog:
870
+ self.geneUniqueId = self.geneUniqueId + self.hog_generef
871
+ self.hogsId.append(self.current_hog)
872
+
873
+ self.current_hog = None
874
+ self.hog_generef = []
875
+ self.add_this_hog = False
876
+
877
+ def data(self, data):
878
+ # Ignore data inside nodes
879
+ pass
880
+
881
+ def close(self):
882
+ # Nothing special to do here
883
+ return
884
+
885
+
886
+
887
+
888
+ class PhyloXMLToETE:
889
+ def __init__(self, node_factory=None):
890
+ self.root = None
891
+ self.stack = []
892
+ self.current_tag = None
893
+ self.buffer = ''
894
+ self.in_taxonomy = False
895
+ self.taxonomy_data = None
896
+ self.in_clade = False
897
+ from .taxonomy import Tree
898
+ self.node_factory = node_factory if node_factory else Tree
899
+
900
+ def start(self, tag, attrib):
901
+ tag = self._strip_ns(tag)
902
+ self.current_tag = tag
903
+
904
+ if tag == "clade":
905
+ dist = attrib.get("branch_length", None)
906
+ node = self.node_factory({"dist": dist})
907
+ if len(self.stack) > 0:
908
+ self.stack[-1].add_child(node)
909
+ else:
910
+ self.root = node
911
+ self.stack.append(node)
912
+
913
+ elif tag == "taxonomy":
914
+ self.in_taxonomy = True
915
+ self.taxonomy_data = {}
916
+
917
+ def end(self, tag):
918
+ tag = self._strip_ns(tag)
919
+
920
+ if tag == "name" and not self.in_taxonomy:
921
+ if len(self.stack) == 0:
922
+ self.buffer = ""
923
+ return
924
+ # name of clade. -> set the name of the last node in the stack
925
+ self.stack[-1].name = self.buffer.strip()
926
+
927
+ elif self.in_taxonomy:
928
+ if tag in ('scientific_name', 'id', 'code'):
929
+ self.taxonomy_data[tag] = self.buffer.strip()
930
+
931
+ elif tag == "taxonomy":
932
+ node = self.stack[-1]
933
+ sci_name = self.taxonomy_data.get('scientific_name')
934
+ if sci_name:
935
+ node.add_prop('scientific_name', sci_name)
936
+ taxon_id = self.taxonomy_data.get('id')
937
+ if taxon_id:
938
+ node.add_prop('taxon_id', taxon_id)
939
+ code = self.taxonomy_data.get('code')
940
+ if code:
941
+ node.add_prop('code', code)
942
+ self.in_taxonomy = False
943
+ self.taxonomy_data = None
944
+
945
+ elif tag == "clade":
946
+ self.stack.pop()
947
+
948
+ self.buffer = ""
949
+ self.current_tag = None
950
+
951
+ def data(self, data):
952
+ if self.current_tag:
953
+ self.buffer += data
954
+
955
+ def close(self):
956
+ return self.root
957
+
958
+ def _strip_ns(self, tag):
959
+ return tag.split('}', 1)[-1] if '}' in tag else tag