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/TreeProfile.py +408 -0
- pyham/__init__.py +20 -0
- pyham/_version.py +24 -0
- pyham/abstractgene.py +726 -0
- pyham/genome.py +148 -0
- pyham/ham.py +984 -0
- pyham/id_formats.py +137 -0
- pyham/iham.py +364 -0
- pyham/mapper.py +414 -0
- pyham/parsers.py +959 -0
- pyham/taxonomy.py +349 -0
- pyham/utils.py +31 -0
- pyham/validation.py +914 -0
- pyham-2.0.0.dist-info/METADATA +200 -0
- pyham-2.0.0.dist-info/RECORD +19 -0
- pyham-2.0.0.dist-info/WHEEL +5 -0
- pyham-2.0.0.dist-info/entry_points.txt +2 -0
- pyham-2.0.0.dist-info/licenses/LICENSE.txt +19 -0
- pyham-2.0.0.dist-info/top_level.txt +1 -0
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())
|