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/validation.py ADDED
@@ -0,0 +1,914 @@
1
+ """Check a species tree and an OrthoXML file against each other before analysing them with pyHam.
2
+
3
+ Most problems users hit when loading data come from a species tree that doesn't match the OrthoXML
4
+ file: species named differently, levels missing from the tree, or a group nesting that contradicts
5
+ the tree. :func:`validate_input` checks for all of these in one go and returns a
6
+ :class:`ValidationReport` listing every problem found, instead of failing at the first one.
7
+
8
+ The checks run in four stages:
9
+
10
+ 1. **Species tree**: can it be read, does every node have a name, are the names unique?
11
+ 2. **OrthoXML**: duplicate species or gene ids, references to undefined genes, genes used twice.
12
+ 3. **Tree vs. OrthoXML**: is every OrthoXML species a leaf of the tree, and is every level the
13
+ OrthoXML claims for its HOGs (``TaxRange``/``taxid``) a node of the tree?
14
+ 4. **Trial build**: if nothing above is an error, every family is built exactly as :obj:`pyham.Ham`
15
+ would, but a family that fails is recorded and skipped instead of stopping the whole parse.
16
+
17
+ The same checks are available from the command line::
18
+
19
+ pyham-validate species_tree.nwk hogs.orthoxml
20
+ """
21
+
22
+ import argparse
23
+ import collections
24
+ import difflib
25
+ import gzip
26
+ import io
27
+ import os
28
+ import re
29
+ import sys
30
+ import textwrap
31
+ import types
32
+ from dataclasses import dataclass, field
33
+ from typing import List, Optional
34
+ from xml.etree.ElementTree import XMLParser, iterparse, ParseError
35
+
36
+ __all__ = ["validate_input", "ValidationReport", "ValidationIssue", "InputValidationError"]
37
+
38
+ ERROR = "error"
39
+ WARNING = "warning"
40
+ NOTE = "note"
41
+ _SEVERITY_ORDER = {ERROR: 0, WARNING: 1, NOTE: 2}
42
+
43
+ _NS = "{http://orthoXML.org/2011/}"
44
+ _PHYLOXML_TAG_ATTR = {"clade_name": "name", "taxonomy_scientific_name": "scientific_name", "taxonomy_code": "code"}
45
+ _SPECIES_CODE_RE = re.compile(r"^[A-Z][A-Z0-9]{2,4}$")
46
+ _MAX_FUZZY_LOOKUPS = 50
47
+
48
+
49
+ @dataclass
50
+ class ValidationIssue:
51
+ """One problem, or noteworthy fact, found in the inputs.
52
+
53
+ Attributes:
54
+ | severity (:obj:`str`): ``'error'`` (pyHam will fail, or silently build something wrong),
55
+ ``'warning'`` (pyHam works, but probably not as intended) or ``'note'`` (worth knowing).
56
+ | code (:obj:`str`): stable identifier of the kind of issue, e.g. ``'species-not-in-tree'``.
57
+ | summary (:obj:`str`): one-sentence description.
58
+ | items (:obj:`list` of :obj:`str`): every affected entity, one description each. The printed
59
+ report shows only the first few; this list is always complete.
60
+ | hint (:obj:`str`): suggested fix, when one can be inferred from the data.
61
+ | data (:obj:`dict`): machine-readable details, specific to each code.
62
+ """
63
+ severity: str
64
+ code: str
65
+ summary: str
66
+ items: List[str] = field(default_factory=list)
67
+ hint: Optional[str] = None
68
+ data: dict = field(default_factory=dict)
69
+
70
+
71
+ class ValidationReport:
72
+ """Result of :func:`validate_input`.
73
+
74
+ Print it for a human-readable report, or inspect :attr:`issues` (or :attr:`errors`,
75
+ :attr:`warnings`, :attr:`notes`) programmatically.
76
+
77
+ Attributes:
78
+ | inputs (:obj:`dict`): short description of each input that was checked.
79
+ | issues (:obj:`list` of :obj:`ValidationIssue`): everything found, in the order it was found.
80
+ | stats (:obj:`dict`): counts gathered along the way (species, genes, families, ...).
81
+ | trial_build (:obj:`str`): outcome of the trial build, or why it was skipped. None if not applicable.
82
+ """
83
+
84
+ def __init__(self):
85
+ self.inputs = {}
86
+ self.issues = []
87
+ self.stats = {}
88
+ self.trial_build = "not run"
89
+
90
+ def add(self, severity, code, summary, items=None, hint=None, data=None):
91
+ issue = ValidationIssue(severity, code, summary, list(items or []), hint, dict(data or {}))
92
+ self.issues.append(issue)
93
+ return issue
94
+
95
+ @property
96
+ def errors(self):
97
+ return [i for i in self.issues if i.severity == ERROR]
98
+
99
+ @property
100
+ def warnings(self):
101
+ return [i for i in self.issues if i.severity == WARNING]
102
+
103
+ @property
104
+ def notes(self):
105
+ return [i for i in self.issues if i.severity == NOTE]
106
+
107
+ @property
108
+ def ok(self):
109
+ """True if no errors were found (warnings and notes are allowed)."""
110
+ return not self.errors
111
+
112
+ def raise_for_errors(self):
113
+ """Raise :obj:`InputValidationError` if the report contains any error."""
114
+ if self.errors:
115
+ raise InputValidationError(self)
116
+
117
+ def format(self, max_items=10):
118
+ """Render the report as text, showing at most `max_items` affected entities per issue."""
119
+ counts = [_plural(len(self.errors), "error"), _plural(len(self.warnings), "warning"),
120
+ _plural(len(self.notes), "note")]
121
+ lines = ["pyHam input check: " + ", ".join(counts)]
122
+ width = max((len(k) for k in self.inputs), default=0)
123
+ for label, desc in self.inputs.items():
124
+ lines.append(f" {label + ':':<{width + 1}} {desc}")
125
+
126
+ for issue in sorted(self.issues, key=lambda i: _SEVERITY_ORDER[i.severity]):
127
+ lines.append("")
128
+ lines.append(f"{issue.severity.upper()} [{issue.code}] {issue.summary}")
129
+ shown = issue.items if max_items is None else issue.items[:max_items]
130
+ for item in shown:
131
+ first, *rest = item.split("\n")
132
+ lines.append(f" - {first}")
133
+ if rest:
134
+ lines.extend(f" {line}" for line in textwrap.dedent("\n".join(rest)).split("\n"))
135
+ if len(shown) < len(issue.items):
136
+ lines.append(f" ... and {len(issue.items) - len(shown)} more")
137
+ if issue.hint:
138
+ lines.append(f" hint: {issue.hint}")
139
+
140
+ if not self.issues:
141
+ lines += ["", "No problems found."]
142
+ if self.trial_build is not None:
143
+ lines += ["", f"Trial build: {self.trial_build}"]
144
+ return "\n".join(lines)
145
+
146
+ def __str__(self):
147
+ return self.format()
148
+
149
+ def __repr__(self):
150
+ return (f"<ValidationReport: {len(self.errors)} errors, {len(self.warnings)} warnings, "
151
+ f"{len(self.notes)} notes>")
152
+
153
+
154
+ class InputValidationError(KeyError):
155
+ """Raised when the inputs given to pyHam contain errors. The full :obj:`ValidationReport` is
156
+ available as the `report` attribute.
157
+
158
+ Subclasses :obj:`KeyError` because that is what pyHam raised for these problems before.
159
+ """
160
+
161
+ def __init__(self, report, footer=None):
162
+ message = report.format()
163
+ if footer:
164
+ message += "\n\n" + footer
165
+ super().__init__(message)
166
+ self.report = report
167
+
168
+ def __str__(self):
169
+ return self.args[0]
170
+
171
+
172
+ _IRREGULAR_PLURALS = {"leaf": "leaves", "tree leaf": "tree leaves", "family": "families", "species": "species"}
173
+
174
+
175
+ def _plural(n, word):
176
+ return f"{n:,} {word if n == 1 else _IRREGULAR_PLURALS.get(word, word + 's')}"
177
+
178
+
179
+ def _counted(n, word, verb_singular, verb_plural):
180
+ """e.g. _counted(2, 'leaf', 'has', 'have') -> '2 leaves have'"""
181
+ return f"{_plural(n, word)} {verb_singular if n == 1 else verb_plural}"
182
+
183
+
184
+ def _src_label(src, is_string):
185
+ if is_string:
186
+ return "<string>"
187
+ return str(src)
188
+
189
+
190
+ # ----------------------------------------------------------------------------------------------
191
+ # Names in the species tree
192
+ # ----------------------------------------------------------------------------------------------
193
+
194
+ @dataclass
195
+ class _TreeNames:
196
+ """Names available in a species tree, under the chosen settings and under the alternatives."""
197
+ leaves: list
198
+ internal: list
199
+ # internal node labels as written in the file (newick only), even when use_internal_name=False
200
+ raw_internal_labels: Optional[set] = None
201
+ # phyloxml only: {tag: set of values} for every name tag, to suggest a better choice
202
+ alt_leaf_names: dict = field(default_factory=dict)
203
+ alt_internal_names: dict = field(default_factory=dict)
204
+ leaf_tag: Optional[str] = None
205
+ internal_tag: Optional[str] = None
206
+
207
+ @classmethod
208
+ def from_taxonomy(cls, taxonomy):
209
+ leaves, internal = [], []
210
+ alt_leaf = collections.defaultdict(set)
211
+ alt_internal = collections.defaultdict(set)
212
+ for node in taxonomy.tree.traverse():
213
+ (leaves if node.is_leaf else internal).append(node.name)
214
+ for tag, attr in _PHYLOXML_TAG_ATTR.items():
215
+ value = node.props.get(attr) if attr != "name" else None
216
+ if value:
217
+ (alt_leaf if node.is_leaf else alt_internal)[tag].add(value)
218
+ return cls(leaves, internal, alt_leaf_names=dict(alt_leaf), alt_internal_names=dict(alt_internal))
219
+
220
+
221
+ def _normalize_name(name):
222
+ return " ".join(name.strip().strip("'\"").replace("_", " ").lower().split())
223
+
224
+
225
+ def _describe_name_difference(a, b):
226
+ if a.replace("_", " ") == b.replace("_", " "):
227
+ return "underscores vs spaces"
228
+ if a.lower() == b.lower():
229
+ return "letter case"
230
+ if a.strip().strip("'\"").strip() == b.strip().strip("'\"").strip():
231
+ return "quotes or surrounding whitespace"
232
+ return "underscores/spaces, letter case or quotes"
233
+
234
+
235
+ def _species_issue(report, unmapped, n_species, tree_names, all_species=None):
236
+ """Add a 'species-not-in-tree' error for the (name, reason) pairs in `unmapped`."""
237
+ leaves = [n for n in tree_names.leaves if n]
238
+ internal = {n for n in tree_names.internal if n}
239
+ by_norm = collections.defaultdict(list)
240
+ for leaf in leaves:
241
+ by_norm[_normalize_name(leaf)].append(leaf)
242
+
243
+ items, suggestions = [], {}
244
+ differences = collections.Counter()
245
+ unexplained = fuzzy_lookups = 0
246
+ for name, _reason in unmapped:
247
+ if name in internal:
248
+ detail = "is an internal node of the tree, not a leaf"
249
+ unexplained += 1
250
+ elif by_norm.get(_normalize_name(name)):
251
+ match = by_norm[_normalize_name(name)][0]
252
+ difference = _describe_name_difference(name, match)
253
+ differences[difference] += 1
254
+ suggestions[name] = match
255
+ detail = f'the tree has "{match}" ({difference})'
256
+ else:
257
+ unexplained += 1
258
+ detail = None
259
+ if fuzzy_lookups < _MAX_FUZZY_LOOKUPS:
260
+ fuzzy_lookups += 1
261
+ close = difflib.get_close_matches(name, leaves, n=1, cutoff=0.8)
262
+ if close:
263
+ suggestions[name] = close[0]
264
+ detail = f'closest tree leaf: "{close[0]}"'
265
+ else:
266
+ detail = "no similar leaf in the tree"
267
+ items.append(f'"{name}": {detail}' if detail else f'"{name}"')
268
+
269
+ n_matched = n_species - len(unmapped)
270
+ hints = []
271
+ if unmapped and not unexplained:
272
+ kinds = " and ".join(differences)
273
+ hints.append(f"All of these differ from a tree leaf only in {kinds}; make the names identical in "
274
+ f"both files.")
275
+
276
+ unmapped_names = [name for name, _ in unmapped]
277
+ if unexplained and leaves:
278
+ xml_codes = sum(bool(_SPECIES_CODE_RE.match(n)) for n in unmapped_names) / len(unmapped_names)
279
+ tree_codes = sum(bool(_SPECIES_CODE_RE.match(n)) for n in leaves) / len(leaves)
280
+ if xml_codes >= 0.8 and tree_codes <= 0.2:
281
+ hints.append("The OrthoXML names species by short codes (like \"HUMAN\"), but the tree leaves are not "
282
+ "codes.")
283
+ elif xml_codes <= 0.2 and tree_codes >= 0.8:
284
+ hints.append("The tree leaves are short species codes (like \"HUMAN\"), but the OrthoXML uses other "
285
+ "names.")
286
+
287
+ if all_species is not None:
288
+ species_set = set(all_species)
289
+ for tag, values in tree_names.alt_leaf_names.items():
290
+ n_alt = len(species_set & values)
291
+ if tag != tree_names.leaf_tag and n_alt > n_matched:
292
+ hints.append(f"phyloxml_leaf_name_tag='{tag}' would match {n_alt:,} of {n_species:,} species "
293
+ f"(currently {n_matched:,}).")
294
+
295
+ if not hints:
296
+ if n_matched == 0:
297
+ hints.append("None of the OrthoXML species are in the tree. Is this the species tree the HOGs were "
298
+ "inferred with?")
299
+ else:
300
+ hints.append("Every OrthoXML species must be a leaf of the species tree, with exactly the same name. "
301
+ "Use the species tree the HOGs were inferred with.")
302
+
303
+ verb = "is not a leaf" if len(unmapped) == 1 else "are not leaves"
304
+ report.add(ERROR, "species-not-in-tree",
305
+ f"{len(unmapped):,} of {n_species:,} OrthoXML species {verb} of the species tree.",
306
+ items, " ".join(hints), {"species": unmapped_names, "suggestions": suggestions})
307
+
308
+
309
+ def species_not_in_tree_error(unmapped, n_species, taxonomy):
310
+ """Build the :obj:`InputValidationError` that :obj:`pyham.Ham` raises when OrthoXML species are
311
+ missing from the species tree."""
312
+ report = ValidationReport()
313
+ report.trial_build = None
314
+ _species_issue(report, unmapped, n_species, _TreeNames.from_taxonomy(taxonomy))
315
+ return InputValidationError(report, footer="Run pyham.validate_input() with the same inputs to check "
316
+ "everything else as well.")
317
+
318
+
319
+ # ----------------------------------------------------------------------------------------------
320
+ # Stage 1: species tree
321
+ # ----------------------------------------------------------------------------------------------
322
+
323
+ def _detect_tree_format(tree, tree_format):
324
+ if tree_format is not None:
325
+ return tree_format
326
+ if isinstance(tree, (str, os.PathLike)) and os.path.isfile(tree):
327
+ name = str(tree).lower()
328
+ if name.endswith(".gz"):
329
+ name = name[:-3]
330
+ return "phyloxml" if name.endswith((".xml", ".phyloxml")) else "newick"
331
+ return "newick_string"
332
+
333
+
334
+ def _read_newick(tree, tree_format, report):
335
+ from .taxonomy import create_tree_from_newick
336
+ try:
337
+ # use_internal_name=True keeps the labels as written; they're replaced later if needed
338
+ return create_tree_from_newick(tree, tree_format=tree_format, use_internal_name=True)
339
+ except Exception as e:
340
+ report.add(ERROR, "tree-unreadable", f"The species tree can't be read as newick: {e}",
341
+ hint="Names that contain spaces, quotes, parentheses, commas, colons or semicolons must be "
342
+ "quoted in newick, e.g. 'Aster yellows witches''-broom phytoplasma' (an apostrophe "
343
+ "inside single quotes is written twice).")
344
+ return None
345
+
346
+
347
+ def _read_phyloxml(tree, report):
348
+ from .parsers import PhyloXMLToETE
349
+ factory = PhyloXMLToETE()
350
+ parser = XMLParser(target=factory)
351
+ open_ = gzip.open if str(tree).endswith(".gz") else open
352
+ try:
353
+ with open_(tree, "rt") as fh:
354
+ for line in fh:
355
+ parser.feed(line)
356
+ parser.close()
357
+ except Exception as e:
358
+ report.add(ERROR, "tree-unreadable", f"The species tree can't be read as phyloXML: {e}")
359
+ return None
360
+ if factory.root is None:
361
+ report.add(ERROR, "tree-unreadable", "The phyloXML file contains no <clade> elements.")
362
+ return None
363
+ return factory.root
364
+
365
+
366
+ def _name_field(node, tag):
367
+ """Value of a phyloxml name field; the clade name is kept aside as node.name gets overwritten."""
368
+ return node.props.get("_clade_name") if tag == "clade_name" else node.props.get(_PHYLOXML_TAG_ATTR[tag])
369
+
370
+
371
+ def _node_label(node):
372
+ """The node's name or, failing that, any other name field it has."""
373
+ if node.name:
374
+ return node.name
375
+ for tag in _PHYLOXML_TAG_ATTR:
376
+ if _name_field(node, tag):
377
+ return f"{tag}={_name_field(node, tag)!r}"
378
+ return "?"
379
+
380
+
381
+ def _describe_node(node, max_names=3):
382
+ names = [_node_label(leaf) for leaf in node.leaves()]
383
+ shown = ", ".join(names[:max_names]) + (", ..." if len(names) > max_names else "")
384
+ return f"{len(names)} leaves: {shown}" if len(names) > 1 else f"leaf {shown}"
385
+
386
+
387
+ def _describe_unnamed_leaf(node, alternatives):
388
+ values = [f"{tag}={_name_field(node, tag)!r}" for tag in alternatives if _name_field(node, tag)]
389
+ if values:
390
+ return "leaf with " + ", ".join(values)
391
+ return f"unnamed leaf next to {_describe_node(node.up)}" if node.up else "unnamed leaf"
392
+
393
+
394
+ def _check_tree(tree, tree_format, use_internal_name, leaf_tag, internal_tag, report):
395
+ """Stage 1. Returns (Taxonomy or None, _TreeNames or None)."""
396
+ from .taxonomy import Taxonomy
397
+
398
+ is_phyloxml = tree_format == "phyloxml"
399
+ root = _read_phyloxml(tree, report) if is_phyloxml else _read_newick(tree, tree_format, report)
400
+ if root is None:
401
+ return None, None
402
+
403
+ names = _TreeNames([], [], leaf_tag=leaf_tag if is_phyloxml else None,
404
+ internal_tag=internal_tag if is_phyloxml else None)
405
+ if is_phyloxml:
406
+ alt_leaf, alt_internal = collections.defaultdict(set), collections.defaultdict(set)
407
+ for node in root.traverse():
408
+ if node.name:
409
+ node.add_prop("_clade_name", node.name)
410
+ for tag, attr in _PHYLOXML_TAG_ATTR.items():
411
+ value = node.props.get(attr)
412
+ if value:
413
+ (alt_leaf if node.is_leaf else alt_internal)[tag].add(value)
414
+ names.alt_leaf_names, names.alt_internal_names = dict(alt_leaf), dict(alt_internal)
415
+ leaf_attr, internal_attr = _PHYLOXML_TAG_ATTR[leaf_tag], _PHYLOXML_TAG_ATTR[internal_tag]
416
+ for node in root.traverse("postorder"):
417
+ if node.is_leaf:
418
+ node.name = node.props.get(leaf_attr) or None
419
+ elif use_internal_name:
420
+ node.name = node.props.get(internal_attr) or None
421
+ else:
422
+ names.raw_internal_labels = {n.name for n in root.traverse() if not n.is_leaf and n.name}
423
+
424
+ if not use_internal_name:
425
+ for node in root.traverse("postorder"):
426
+ if not node.is_leaf:
427
+ node.name = "/".join(child.name or "?" for child in node.get_children())
428
+
429
+ leaves = list(root.leaves())
430
+ internal = [n for n in root.traverse() if not n.is_leaf]
431
+ names.leaves = [n.name for n in leaves]
432
+ names.internal = [n.name for n in internal]
433
+ report.stats.update(tree_leaves=len(leaves), tree_internal_nodes=len(internal))
434
+
435
+ def tag_hint(alternatives, current, n_nodes, option, noun):
436
+ better = [tag for tag, values in alternatives.items() if tag != current and len(values) >= n_nodes]
437
+ return f"Every {noun} has a value for {option}='{better[0]}'." if better else ""
438
+
439
+ unnamed_leaves = [n for n in leaves if not n.name]
440
+ if unnamed_leaves:
441
+ hint = None
442
+ if is_phyloxml:
443
+ hint = (f"pyHam names leaves from phyloxml_leaf_name_tag='{leaf_tag}'. " +
444
+ tag_hint(names.alt_leaf_names, leaf_tag, len(leaves), "phyloxml_leaf_name_tag", "leaf")).strip()
445
+ report.add(ERROR, "unnamed-leaves", f"{_counted(len(unnamed_leaves), 'leaf', 'has', 'have')} no name in "
446
+ f"the species tree.",
447
+ [_describe_unnamed_leaf(n, names.alt_leaf_names) for n in unnamed_leaves], hint)
448
+
449
+ if use_internal_name:
450
+ # newick roots may be unnamed; phyloxml roots need a name like every other node
451
+ unnamed_internal = [n for n in internal if not n.name and (is_phyloxml or not n.is_root)]
452
+ if unnamed_internal:
453
+ hint = "Set use_internal_name=False to let pyHam name internal nodes after their children."
454
+ if is_phyloxml:
455
+ hint = (tag_hint(names.alt_internal_names, internal_tag, len(internal),
456
+ "phyloxml_internal_name_tag", "internal node") + " " + hint).strip()
457
+ report.add(ERROR, "unnamed-internal-nodes",
458
+ f"{_counted(len(unnamed_internal), 'internal node', 'has', 'have')} no name in the species "
459
+ f"tree, but use_internal_name=True needs one for every internal node.",
460
+ [f"node above {_describe_node(n)}" for n in unnamed_internal], hint)
461
+
462
+ leaf_counts = collections.Counter(n for n in names.leaves if n)
463
+ dup_leaves = {n: c for n, c in leaf_counts.items() if c > 1}
464
+ if dup_leaves:
465
+ report.add(ERROR, "duplicate-leaf-names", f"{_counted(len(dup_leaves), 'leaf name', 'occurs', 'occur')} "
466
+ f"more than once in the species tree.", [f'"{n}" ({c} times)' for n, c in dup_leaves.items()],
467
+ "Each species must appear exactly once in the species tree.")
468
+
469
+ if use_internal_name:
470
+ internal_counts = collections.Counter(n for n in names.internal if n)
471
+ dup_internal = {n: c for n, c in internal_counts.items() if c > 1}
472
+ if dup_internal:
473
+ numeric = sum(_is_number(n) for n in internal_counts) / max(len(internal_counts), 1)
474
+ if numeric >= 0.5:
475
+ hint = ("Most internal node labels are numbers, probably support values rather than taxon "
476
+ "names. Set use_internal_name=False to let pyHam name internal nodes after their children.")
477
+ else:
478
+ hint = ("pyHam identifies ancestral levels by name, so internal names must be unique. Rename "
479
+ "them, or set use_internal_name=False to name internal nodes after their children.")
480
+ report.add(ERROR, "duplicate-internal-names",
481
+ f"{_counted(len(dup_internal), 'internal node name', 'occurs', 'occur')} more than once in the "
482
+ f"species tree.",
483
+ [f'"{n}" ({c} times)' for n, c in dup_internal.items()], hint)
484
+
485
+ shared = sorted(set(leaf_counts) & {n for n in names.internal if n})
486
+ if shared:
487
+ report.add(WARNING, "leaf-and-internal-name", f"{_counted(len(shared), 'name', 'is', 'are')} used for both "
488
+ f"a leaf and an internal node of the species tree, so looking up a level by name is ambiguous.",
489
+ [f'"{n}"' for n in shared])
490
+
491
+ if report.errors:
492
+ return None, names
493
+ try:
494
+ return Taxonomy.from_ete_tree(root), names
495
+ except Exception as e:
496
+ report.add(ERROR, "tree-invalid", f"pyHam can't use this species tree: {e}")
497
+ return None, names
498
+
499
+
500
+ def _is_number(text):
501
+ try:
502
+ float(text)
503
+ return True
504
+ except (TypeError, ValueError):
505
+ return False
506
+
507
+
508
+ # ----------------------------------------------------------------------------------------------
509
+ # Stages 2 and 3: OrthoXML content, and OrthoXML vs. tree
510
+ # ----------------------------------------------------------------------------------------------
511
+
512
+ class _LevelResolver:
513
+ """Decides whether a HOG's claimed level is a node of the tree, the same way the parser does."""
514
+
515
+ def __init__(self, taxonomy, tree_names):
516
+ self.taxonomy = taxonomy
517
+ self.names = {n for n in tree_names.leaves + tree_names.internal if n} if tree_names else set()
518
+ self._cache = {}
519
+
520
+ def resolves(self, key, value):
521
+ if (key, value) not in self._cache:
522
+ self._cache[(key, value)] = self._resolves(key, value)
523
+ return self._cache[(key, value)]
524
+
525
+ def _resolves(self, key, value):
526
+ if self.taxonomy is None:
527
+ return key != "taxonId" and value in self.names
528
+ if key == "taxonId":
529
+ try:
530
+ return len(list(self.taxonomy.nodes_by_attr(id=int(value)))) == 1
531
+ except ValueError:
532
+ return False
533
+ try:
534
+ self.taxonomy.get_node_by_name(value)
535
+ return True
536
+ except KeyError:
537
+ return False
538
+
539
+
540
+ def _scan_orthoxml(open_orthoxml, resolver, report):
541
+ """Stage 2 and the level part of stage 3, in one streaming pass. Returns the species names in
542
+ file order and the unresolved levels ({label: [count, first family]}), or (None, None) if the
543
+ file can't be parsed."""
544
+ species, gene_species = [], {}
545
+ duplicate_genes = collections.defaultdict(list)
546
+ references = collections.defaultdict(list)
547
+ unknown_refs = []
548
+ unresolved_levels = collections.OrderedDict()
549
+ n_families = n_groups = n_claimed = 0
550
+ current_species = family = None
551
+ frames = []
552
+
553
+ try:
554
+ with open_orthoxml() as fh:
555
+ for event, elem in iterparse(fh, events=("start", "end")):
556
+ tag = elem.tag
557
+ if event == "start":
558
+ if tag == _NS + "species":
559
+ current_species = elem.get("name")
560
+ species.append(current_species)
561
+ elif tag == _NS + "gene":
562
+ gene_id = elem.get("id")
563
+ if gene_id in gene_species:
564
+ duplicate_genes[gene_id].append(current_species)
565
+ else:
566
+ gene_species[gene_id] = current_species
567
+ elif tag in (_NS + "orthologGroup", _NS + "paralogGroup"):
568
+ if not frames:
569
+ n_families += 1
570
+ family = elem.get("id") or f"#{n_families} (without id)"
571
+ claims = {}
572
+ if tag == _NS + "orthologGroup":
573
+ n_groups += 1
574
+ if elem.get("taxonId") is not None:
575
+ claims["taxonId"] = elem.get("taxonId")
576
+ frames.append((tag, claims))
577
+ elif tag == _NS + "property" and frames and frames[-1][0] == _NS + "orthologGroup":
578
+ if elem.get("name") in ("TaxRange", "taxid"):
579
+ frames[-1][1].setdefault(elem.get("name"), elem.get("value"))
580
+ elif tag == _NS + "geneRef" and frames:
581
+ gene_id = elem.get("id")
582
+ if gene_id not in gene_species:
583
+ unknown_refs.append((gene_id, family))
584
+ else:
585
+ references[gene_id].append(family)
586
+ else:
587
+ if tag in (_NS + "orthologGroup", _NS + "paralogGroup"):
588
+ group_tag, claims = frames.pop()
589
+ if group_tag == _NS + "orthologGroup" and claims:
590
+ n_claimed += 1
591
+ if not any(resolver.resolves(k, v) for k, v in claims.items()):
592
+ label = claims.get("TaxRange") or (
593
+ f"taxid={claims['taxid']}" if "taxid" in claims else f"taxonId={claims['taxonId']}")
594
+ entry = unresolved_levels.setdefault(label, [0, family])
595
+ entry[0] += 1
596
+ elem.clear()
597
+ except (ParseError, OSError) as e:
598
+ report.add(ERROR, "orthoxml-unreadable", f"The OrthoXML file can't be read: {e}")
599
+ return None, None
600
+
601
+ report.stats.update(species=len(species), genes=len(gene_species), families=n_families, hogs=n_groups,
602
+ hogs_with_level=n_claimed)
603
+
604
+ dup_species = {n: c for n, c in collections.Counter(species).items() if c > 1}
605
+ if dup_species:
606
+ report.add(ERROR, "duplicate-species", f"{_counted(len(dup_species), 'species', 'is', 'are')} defined more "
607
+ f"than once in the OrthoXML.", [f'"{n}" ({c} times)' for n, c in dup_species.items()],
608
+ "Merge the genes of each species into a single <species> element.")
609
+ if duplicate_genes:
610
+ report.add(ERROR, "duplicate-gene-ids", f"{_counted(len(duplicate_genes), 'gene id', 'is', 'are')} defined "
611
+ f"more than once in the OrthoXML.",
612
+ [f'gene id {g} (in {gene_species[g]}, and again in {", ".join(map(str, sp))})'
613
+ for g, sp in duplicate_genes.items()],
614
+ "Gene ids must be unique across the whole file.")
615
+ if unknown_refs:
616
+ report.add(ERROR, "unknown-gene-reference", f"{_counted(len(unknown_refs), 'geneRef', 'points', 'point')} "
617
+ f"to a gene id that isn't defined in the <species> section.",
618
+ [f"geneRef {g} in family {f}" for g, f in unknown_refs])
619
+ reused = {g: fams for g, fams in references.items() if len(fams) > 1}
620
+ if reused:
621
+ report.add(ERROR, "gene-referenced-twice", f"{_counted(len(reused), 'gene', 'is', 'are')} referenced more "
622
+ f"than once in the groups. pyHam would place such a gene in several HOGs and keep only the last "
623
+ f"as its parent.",
624
+ [f"gene {g} ({gene_species[g]}) in families {', '.join(dict.fromkeys(fams))}"
625
+ for g, fams in reused.items()],
626
+ "Each gene must belong to at most one group.")
627
+ singletons = len(gene_species) - len(references)
628
+ if singletons and n_families:
629
+ report.add(NOTE, "singleton-genes", f"{singletons:,} of {len(gene_species):,} genes are not in any group. "
630
+ f"pyHam keeps them as genes without a HOG, so HOG-based tools don't apply to them.")
631
+
632
+ report.stats["levels_not_in_tree"] = sum(c for c, _ in unresolved_levels.values())
633
+ if n_groups and not n_claimed:
634
+ report.add(NOTE, "no-level-annotations", "No HOG states its taxonomic level (TaxRange/taxid), so pyHam "
635
+ "infers each HOG's level as the common ancestor of its member species. The nesting of the "
636
+ "groups must then agree with the species tree, e.g. come from gene trees reconciled with it.")
637
+ return species, unresolved_levels
638
+
639
+
640
+ def _level_issue(report, unresolved, n_claimed, tree_names, use_internal_name, tree_format):
641
+ if not unresolved:
642
+ return
643
+ n_hogs = sum(count for count, _ in unresolved.values())
644
+ items = [f'"{label}": {_plural(count, "HOG")}, e.g. in family {family}'
645
+ for label, (count, family) in sorted(unresolved.items(), key=lambda kv: -kv[1][0])]
646
+ labels = set(unresolved)
647
+
648
+ hint = None
649
+ if tree_format != "phyloxml" and not use_internal_name and tree_names.raw_internal_labels is not None:
650
+ n_match = len(labels & tree_names.raw_internal_labels)
651
+ if n_match and n_match >= len(labels) / 2:
652
+ hint = (f"The tree's own internal node names match {n_match:,} of these levels. Set "
653
+ f"use_internal_name=True so pyHam uses them.")
654
+ elif not tree_names.raw_internal_labels:
655
+ hint = ("The species tree has no internal node names, so pyHam can't use the levels stated in the "
656
+ "OrthoXML. Use a tree with named internal nodes (e.g. the one the HOGs were inferred with) and "
657
+ "use_internal_name=True.")
658
+ elif tree_format == "phyloxml":
659
+ best = max(((len(labels & values), tag) for tag, values in tree_names.alt_internal_names.items()),
660
+ default=(0, None))
661
+ if best[0] and best[0] >= len(labels) / 2 and (best[1] != tree_names.internal_tag or not use_internal_name):
662
+ hint = (f"phyloxml_internal_name_tag='{best[1]}' with use_internal_name=True would match "
663
+ f"{best[0]:,} of these levels.")
664
+ if hint is None:
665
+ hint = ("These levels are not in the species tree. If it is a pruned or different version of the tree the "
666
+ "HOGs were inferred with, pyHam infers these HOGs' levels from their member species instead, which "
667
+ "can differ from the OrthoXML or make families fail to build. Prefer the original species tree.")
668
+
669
+ report.add(WARNING, "level-not-in-tree",
670
+ f"{n_hogs:,} of {n_claimed:,} HOGs state a taxonomic level that isn't a node of the species tree "
671
+ f"({_plural(len(unresolved), 'distinct level')}). pyHam infers their level from their member species "
672
+ f"instead.", items, hint, {"levels": {k: v[0] for k, v in unresolved.items()}})
673
+
674
+
675
+ def _check_species_mapping(report, species, taxonomy, tree_names):
676
+ if taxonomy is not None:
677
+ unmapped, used = [], collections.defaultdict(list)
678
+ for name in dict.fromkeys(species):
679
+ try:
680
+ used[taxonomy.get_extant_taxa_by_name(name)].append(name)
681
+ except KeyError as e:
682
+ unmapped.append((name, e.args[0] if e.args else str(e)))
683
+ shared = {node: names for node, names in used.items() if len(names) > 1}
684
+ if shared:
685
+ report.add(ERROR, "species-share-leaf", f"{_counted(len(shared), 'tree leaf', 'is', 'are')} matched by "
686
+ f"more than one OrthoXML species.",
687
+ [f'leaf "{node.name}": {", ".join(names)}' for node, names in shared.items()])
688
+ else:
689
+ leaves = set(tree_names.leaves)
690
+ unmapped = [(name, "") for name in dict.fromkeys(species) if name not in leaves]
691
+ if unmapped:
692
+ _species_issue(report, unmapped, len(set(species)), tree_names, all_species=species)
693
+ n_matched = len(set(species)) - len(unmapped)
694
+ unused = len(tree_names.leaves) - n_matched
695
+ if unused > 0 and n_matched > 0:
696
+ report.add(NOTE, "tree-leaves-without-species", f"{unused:,} of {len(tree_names.leaves):,} tree leaves have "
697
+ f"no species in the OrthoXML. This is fine: pyHam only uses the part of the tree it needs.")
698
+
699
+
700
+ # ----------------------------------------------------------------------------------------------
701
+ # Stage 4: trial build
702
+ # ----------------------------------------------------------------------------------------------
703
+
704
+ def _make_tolerant_parser_class():
705
+ from .parsers import OrthoXMLParser
706
+ from .abstractgene import TaxonomicConflictError
707
+
708
+ class TolerantOrthoXMLParser(OrthoXMLParser):
709
+ """OrthoXMLParser that records a family that fails to build, skips the rest of it, and
710
+ continues with the next family. The objects it builds are only good for validation."""
711
+
712
+ def __init__(self, *args, **kwargs):
713
+ super().__init__(*args, **kwargs)
714
+ self.family_failures = []
715
+
716
+ def start(self, tag, attrib):
717
+ depth = len(self.hog_stack)
718
+ try:
719
+ super().start(tag, attrib)
720
+ except (InputValidationError, TaxonomicConflictError):
721
+ raise
722
+ except Exception as e:
723
+ if tag == _NS + "orthologGroup" and len(self.hog_stack) == depth:
724
+ self.hog_stack.append(0) # keep the stack balanced for the matching end()
725
+ self._record_failure(e)
726
+
727
+ def end(self, tag):
728
+ try:
729
+ super().end(tag)
730
+ except (InputValidationError, TaxonomicConflictError):
731
+ raise
732
+ except Exception as e:
733
+ self._record_failure(e)
734
+
735
+ def _record_failure(self, exc):
736
+ self.family_failures.append((self.current_family_id, exc))
737
+ self.paralog_stack = []
738
+ self.in_paralogGroup = None
739
+ self.paralogyNode = None
740
+ if self.hog_stack:
741
+ self.skip_this_hog = True
742
+ else:
743
+ self.current_family_id = None
744
+
745
+ return TolerantOrthoXMLParser
746
+
747
+
748
+ def _trial_build(open_orthoxml, taxonomy, report):
749
+ from .parsers import sniff_id_schema, _format_one_conflict
750
+
751
+ with open_orthoxml(text=True) as fh:
752
+ id_schema = sniff_id_schema(fh)
753
+ target = _make_tolerant_parser_class()(types.SimpleNamespace(taxonomy=taxonomy), id_schema=id_schema)
754
+ parser = XMLParser(target=target)
755
+ try:
756
+ with open_orthoxml(text=True) as fh:
757
+ for line in fh:
758
+ parser.feed(line)
759
+ except Exception as e:
760
+ report.add(ERROR, "trial-build-crashed", f"Building the HOGs stopped outside of any family: "
761
+ f"{type(e).__name__}: {e}")
762
+ report.trial_build = "failed"
763
+ return
764
+
765
+ n_families = report.stats.get("families", 0)
766
+ failures = target.family_failures
767
+ if failures:
768
+ items, messages = [], {}
769
+ for family, exc in failures:
770
+ message = " ".join(str(exc).split())
771
+ messages[family] = f"{type(exc).__name__}: {message}"
772
+ items.append(f"family {family}: {type(exc).__name__}: {_shorten(message, 600)}")
773
+ hint = None
774
+ if any("contradicts the species tree" in m or "older than itself" in m for m in messages.values()):
775
+ hint = ("The group nesting of these families doesn't fit the species tree. If the OrthoXML was "
776
+ "derived from gene trees, reconcile them with this species tree first.")
777
+ report.add(ERROR, "family-build-failed", f"{len(failures):,} of {_plural(n_families, 'family')} can't be "
778
+ f"built by pyHam.", items, hint, {"failures": messages})
779
+
780
+ conflicts = target.taxonomic_conflicts
781
+ if conflicts:
782
+ n_families_hit = len({c.family_id for c in conflicts})
783
+ report.add(ERROR, "taxonomic-conflicts", f"{len(conflicts):,} HOGs in {n_families_hit:,} families state a "
784
+ f"level that conflicts with the species tree.",
785
+ [_format_one_conflict(c) for c in conflicts],
786
+ "pyHam raises TaxonomicConflictError for these. The OrthoXML and the species tree disagree on "
787
+ "where these species belong; use the species tree the HOGs were inferred with.",
788
+ {"conflicts": conflicts})
789
+
790
+ collapsed = target.collapsed_same_level_hogs
791
+ if collapsed:
792
+ report.add(NOTE, "same-level-hogs-collapsed", f"{len(collapsed):,} HOGs sit at the same level as their "
793
+ f"parent and are merged into it, as is usual for 'augmented' HOGs.",
794
+ [str(h) for h in collapsed])
795
+
796
+ n_failed = len({f for f, _ in failures} | {c.family_id for c in conflicts})
797
+ report.stats["families_built"] = n_families - n_failed
798
+ if n_failed:
799
+ report.trial_build = f"{n_families - n_failed:,} of {_plural(n_families, 'family')} built without errors"
800
+ else:
801
+ report.trial_build = f"ok, all {_plural(n_families, 'family')} built"
802
+
803
+
804
+ def _shorten(text, width):
805
+ return text if len(text) <= width else text[:width - 3].rstrip() + "..."
806
+
807
+
808
+ # ----------------------------------------------------------------------------------------------
809
+ # Entry points
810
+ # ----------------------------------------------------------------------------------------------
811
+
812
+ def validate_input(tree_file, hog_file, tree_format=None, use_internal_name=False,
813
+ phyloxml_leaf_name_tag="taxonomy_scientific_name",
814
+ phyloxml_internal_name_tag="taxonomy_scientific_name",
815
+ orthoXML_as_string=False, trial_build=True):
816
+ """Check a species tree and an OrthoXML file for problems that would stop pyHam, or make it
817
+ silently build something different from what the files say.
818
+
819
+ Takes the same arguments as :obj:`pyham.Ham`, so the check reflects what Ham would do with them.
820
+ Unlike Ham, it doesn't stop at the first problem: it reports everything it finds.
821
+
822
+ Args:
823
+ | tree_file (:obj:`str`): path to the species tree file, or a newick string.
824
+ | hog_file (:obj:`str`): path to the OrthoXML file (may be gzipped), or its content if
825
+ `orthoXML_as_string` is True.
826
+ | tree_format (:obj:`str`, optional): 'newick', 'newick_string' or 'phyloxml'. Defaults to
827
+ guessing from the file extension.
828
+ | use_internal_name (:obj:`bool`, optional): as for :obj:`pyham.Ham`. Defaults to False.
829
+ | phyloxml_leaf_name_tag, phyloxml_internal_name_tag (:obj:`str`, optional): as for :obj:`pyham.Ham`.
830
+ | orthoXML_as_string (:obj:`bool`, optional): `hog_file` is the OrthoXML content. Defaults to False.
831
+ | trial_build (:obj:`bool`, optional): also build every family, as Ham would, and report the
832
+ ones that fail. This takes about as long as loading the data with Ham. Defaults to True.
833
+
834
+ Returns:
835
+ :obj:`ValidationReport`: print it for a readable summary, or inspect its `issues`.
836
+ """
837
+ if tree_file is None or hog_file is None:
838
+ raise TypeError("validate_input needs both a species tree and an OrthoXML file.")
839
+ for tag in (phyloxml_leaf_name_tag, phyloxml_internal_name_tag):
840
+ if tag not in _PHYLOXML_TAG_ATTR:
841
+ raise ValueError(f"{tag!r} is not a phyloxml name tag. Options: {', '.join(_PHYLOXML_TAG_ATTR)}.")
842
+
843
+ report = ValidationReport()
844
+ tree_format = _detect_tree_format(tree_file, tree_format)
845
+
846
+ def open_orthoxml(text=False):
847
+ if orthoXML_as_string:
848
+ return io.StringIO(hog_file) if text else io.BytesIO(hog_file.encode("utf-8"))
849
+ open_ = gzip.open if str(hog_file).endswith(".gz") else open
850
+ return open_(hog_file, "rt" if text else "rb")
851
+
852
+ taxonomy, tree_names = _check_tree(tree_file, tree_format, use_internal_name, phyloxml_leaf_name_tag,
853
+ phyloxml_internal_name_tag, report)
854
+ tree_desc = f"{_src_label(tree_file, tree_format == 'newick_string')} ({tree_format.split('_')[0]}"
855
+ if tree_names is not None:
856
+ naming = ("internal names from the file" if use_internal_name
857
+ else "internal nodes named after their children")
858
+ tree_desc += (f", {_plural(report.stats['tree_leaves'], 'leaf')}, "
859
+ f"{_plural(report.stats['tree_internal_nodes'], 'internal node')}, {naming}")
860
+ report.inputs["species tree"] = tree_desc + ")"
861
+
862
+ resolver = _LevelResolver(taxonomy, tree_names)
863
+ species, unresolved_levels = _scan_orthoxml(open_orthoxml, resolver, report)
864
+ if species is not None:
865
+ report.inputs["OrthoXML"] = (f"{_src_label(hog_file, orthoXML_as_string)} "
866
+ f"({_plural(report.stats['species'], 'species')}, "
867
+ f"{_plural(report.stats['genes'], 'gene')}, "
868
+ f"{_plural(report.stats['families'], 'family')})")
869
+ if tree_names is not None:
870
+ _check_species_mapping(report, species, taxonomy, tree_names)
871
+ _level_issue(report, unresolved_levels, report.stats["hogs_with_level"], tree_names,
872
+ use_internal_name, tree_format)
873
+ else:
874
+ report.inputs["OrthoXML"] = _src_label(hog_file, orthoXML_as_string)
875
+
876
+ if not trial_build:
877
+ report.trial_build = "not run (trial_build=False)"
878
+ elif report.errors or taxonomy is None or species is None:
879
+ report.trial_build = "skipped, fix the errors above first"
880
+ else:
881
+ _trial_build(open_orthoxml, taxonomy, report)
882
+ return report
883
+
884
+
885
+ def main(argv=None):
886
+ """Command line entry point: ``pyham-validate TREE ORTHOXML [options]``."""
887
+ p = argparse.ArgumentParser(
888
+ prog="pyham-validate",
889
+ description="Check a species tree and an OrthoXML file for problems before analysing them with pyHam. "
890
+ "Exits with status 1 if any error is found.")
891
+ p.add_argument("tree", help="species tree file (newick or phyloXML)")
892
+ p.add_argument("orthoxml", help="OrthoXML file (may be gzipped)")
893
+ p.add_argument("--tree-format", choices=["newick", "phyloxml"],
894
+ help="format of the tree file (default: guessed from the extension)")
895
+ p.add_argument("--use-internal-name", action="store_true",
896
+ help="use the internal node names of the tree, as Ham(use_internal_name=True)")
897
+ p.add_argument("--phyloxml-leaf-name-tag", choices=list(_PHYLOXML_TAG_ATTR), default="taxonomy_scientific_name")
898
+ p.add_argument("--phyloxml-internal-name-tag", choices=list(_PHYLOXML_TAG_ATTR),
899
+ default="taxonomy_scientific_name")
900
+ p.add_argument("--no-trial-build", action="store_true", help="skip building the families (faster)")
901
+ p.add_argument("--max-items", type=int, default=10, help="entries shown per issue (default: 10; 0 for all)")
902
+ args = p.parse_args(argv)
903
+
904
+ report = validate_input(args.tree, args.orthoxml, tree_format=args.tree_format,
905
+ use_internal_name=args.use_internal_name,
906
+ phyloxml_leaf_name_tag=args.phyloxml_leaf_name_tag,
907
+ phyloxml_internal_name_tag=args.phyloxml_internal_name_tag,
908
+ trial_build=not args.no_trial_build)
909
+ print(report.format(max_items=args.max_items or None))
910
+ return 1 if report.errors else 0
911
+
912
+
913
+ if __name__ == "__main__":
914
+ sys.exit(main())