supporth 0.4.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.
- supporth/__init__.py +5 -0
- supporth/adapters/__init__.py +57 -0
- supporth/adapters/base.py +332 -0
- supporth/adapters/broccoli.py +79 -0
- supporth/adapters/of_config.py +151 -0
- supporth/adapters/oma.py +95 -0
- supporth/adapters/orthofinder.py +305 -0
- supporth/adapters/sonicparanoid.py +334 -0
- supporth/adapters/tabular.py +68 -0
- supporth/canonical.py +137 -0
- supporth/cli.py +404 -0
- supporth/consensus.py +232 -0
- supporth/deps.py +604 -0
- supporth/disk.py +184 -0
- supporth/figures.py +282 -0
- supporth/interpreters.py +220 -0
- supporth/manifest.py +89 -0
- supporth/method_stack.py +49 -0
- supporth/paths.py +55 -0
- supporth/pipeline.py +408 -0
- supporth/proteomes.py +160 -0
- supporth/py.typed +0 -0
- supporth/shell.py +155 -0
- supporth/similarity.py +286 -0
- supporth-0.4.0.dist-info/METADATA +137 -0
- supporth-0.4.0.dist-info/RECORD +30 -0
- supporth-0.4.0.dist-info/WHEEL +5 -0
- supporth-0.4.0.dist-info/entry_points.txt +3 -0
- supporth-0.4.0.dist-info/licenses/LICENSE +21 -0
- supporth-0.4.0.dist-info/top_level.txt +1 -0
supporth/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Predictor adapters: one module per orthology tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Adapter, MethodProfile, StageContext
|
|
6
|
+
from .broccoli import Broccoli
|
|
7
|
+
from .oma import OMA
|
|
8
|
+
from .orthofinder import OrthoFinder
|
|
9
|
+
from .sonicparanoid import SonicParanoid
|
|
10
|
+
|
|
11
|
+
REGISTRY: dict[str, type[Adapter]] = {
|
|
12
|
+
OrthoFinder.name: OrthoFinder,
|
|
13
|
+
SonicParanoid.name: SonicParanoid,
|
|
14
|
+
Broccoli.name: Broccoli,
|
|
15
|
+
OMA.name: OMA,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Predictors SuppOrth installs and launches. OMA is not among them: the user
|
|
19
|
+
# supplies an existing standalone run with --oma.
|
|
20
|
+
DEFAULT_ORDER = (OrthoFinder.name, SonicParanoid.name, Broccoli.name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get(name: str) -> Adapter:
|
|
24
|
+
key = name.strip().lower()
|
|
25
|
+
if key not in REGISTRY:
|
|
26
|
+
known = ", ".join(sorted(REGISTRY))
|
|
27
|
+
raise KeyError(f"unknown predictor {name!r}; available: {known}")
|
|
28
|
+
return REGISTRY[key]()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve(names: str | None) -> list[Adapter]:
|
|
32
|
+
"""Adapters for a comma-separated selection, kept in run order."""
|
|
33
|
+
if not names or names.strip().lower() == "all":
|
|
34
|
+
return [get(n) for n in DEFAULT_ORDER]
|
|
35
|
+
requested = {n.strip().lower() for n in names.split(",") if n.strip()}
|
|
36
|
+
if "oma" in requested:
|
|
37
|
+
raise KeyError(
|
|
38
|
+
"OMA is not run by suppOrth; pass an existing standalone run with --oma PATH"
|
|
39
|
+
)
|
|
40
|
+
unknown = requested - set(REGISTRY)
|
|
41
|
+
if unknown:
|
|
42
|
+
raise KeyError(
|
|
43
|
+
f"unknown predictor(s): {', '.join(sorted(unknown))}; "
|
|
44
|
+
f"available: {', '.join(sorted(REGISTRY))}"
|
|
45
|
+
)
|
|
46
|
+
return [get(n) for n in DEFAULT_ORDER if n in requested]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"Adapter",
|
|
51
|
+
"MethodProfile",
|
|
52
|
+
"StageContext",
|
|
53
|
+
"REGISTRY",
|
|
54
|
+
"DEFAULT_ORDER",
|
|
55
|
+
"get",
|
|
56
|
+
"resolve",
|
|
57
|
+
]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Common interface for installing, checking, running and parsing predictors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from .. import disk, interpreters, manifest, paths, shell
|
|
12
|
+
from ..canonical import PredictorResult
|
|
13
|
+
from ..proteomes import Proteome
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdapterError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotInstalled(AdapterError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MethodProfile:
|
|
26
|
+
"""Methods recorded for one predictor stage."""
|
|
27
|
+
|
|
28
|
+
search: str = "unspecified"
|
|
29
|
+
clustering: str = "unspecified"
|
|
30
|
+
alignment: str = "none"
|
|
31
|
+
tree: str = "none"
|
|
32
|
+
orthology: str = "unspecified"
|
|
33
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"search": self.search,
|
|
38
|
+
"clustering": self.clustering,
|
|
39
|
+
"alignment": self.alignment,
|
|
40
|
+
"tree": self.tree,
|
|
41
|
+
"orthology": self.orthology,
|
|
42
|
+
**self.extra,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class StageContext:
|
|
48
|
+
"""Everything a single predictor stage needs to run."""
|
|
49
|
+
|
|
50
|
+
input_dir: Path
|
|
51
|
+
work_dir: Path
|
|
52
|
+
proteomes: tuple[Proteome, ...] = ()
|
|
53
|
+
sp1: Proteome | None = None
|
|
54
|
+
sp2: Proteome | None = None
|
|
55
|
+
threads: int = 1
|
|
56
|
+
options: list[str] = field(default_factory=list)
|
|
57
|
+
alignment_dir: Path | None = None
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if self.proteomes:
|
|
61
|
+
if self.sp1 is None:
|
|
62
|
+
self.sp1 = self.proteomes[0]
|
|
63
|
+
if self.sp2 is None and len(self.proteomes) > 1:
|
|
64
|
+
self.sp2 = self.proteomes[1]
|
|
65
|
+
elif self.sp1 is not None and self.sp2 is not None:
|
|
66
|
+
self.proteomes = (self.sp1, self.sp2)
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError("StageContext needs at least two proteomes")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def log(self) -> Path:
|
|
72
|
+
return self.work_dir / "stage.log"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Adapter(ABC):
|
|
76
|
+
name: str = ""
|
|
77
|
+
label: str = ""
|
|
78
|
+
repo: str = ""
|
|
79
|
+
default_ref: str = ""
|
|
80
|
+
entrypoint_candidates: tuple[str, ...] = ()
|
|
81
|
+
binaries: tuple[str, ...] = ()
|
|
82
|
+
# True when the predictor puts its own bundled bin directory ahead of PATH,
|
|
83
|
+
# which defeats the shared binary layer unless the bundled copies are linked.
|
|
84
|
+
shadows_path: bool = False
|
|
85
|
+
# Interpreter range the predictor supports, when it is narrower than
|
|
86
|
+
# SuppOrth's own. Empty means "whatever SuppOrth runs on will do".
|
|
87
|
+
python_requires: str = ""
|
|
88
|
+
# Set when the predictor changes behaviour depending on whether its
|
|
89
|
+
# interpreter belongs to a conda-style distribution.
|
|
90
|
+
prefers_conda_interpreter: bool = False
|
|
91
|
+
|
|
92
|
+
# ---- installation -------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def install(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
ref: str | None = None,
|
|
98
|
+
force: bool = False,
|
|
99
|
+
keep_bundled: bool = False,
|
|
100
|
+
python: Path | str | None = None,
|
|
101
|
+
log=print,
|
|
102
|
+
**post_install_options,
|
|
103
|
+
) -> Path:
|
|
104
|
+
ref = ref or self.default_ref
|
|
105
|
+
root = paths.tool_dir(self.name, ref)
|
|
106
|
+
|
|
107
|
+
if root.exists() and force:
|
|
108
|
+
shutil.rmtree(root)
|
|
109
|
+
|
|
110
|
+
if not root.exists():
|
|
111
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
log(f" cloning {self.repo} @ {ref}")
|
|
113
|
+
shell.run(
|
|
114
|
+
[
|
|
115
|
+
"git",
|
|
116
|
+
"clone",
|
|
117
|
+
"--depth",
|
|
118
|
+
"1",
|
|
119
|
+
"--branch",
|
|
120
|
+
ref,
|
|
121
|
+
self.repo,
|
|
122
|
+
str(root),
|
|
123
|
+
],
|
|
124
|
+
log=paths.cache_root() / f"{self.name}-clone.log",
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
log(f" source already present at {root}")
|
|
128
|
+
|
|
129
|
+
commit = self._resolve_commit(root)
|
|
130
|
+
interpreter = self.select_interpreter(python, log=log)
|
|
131
|
+
# The build step must precede entrypoint discovery: tools installed into
|
|
132
|
+
# a private virtualenv only gain their executable once it has run.
|
|
133
|
+
self.post_install(root, interpreter=interpreter, log=log, **post_install_options)
|
|
134
|
+
entrypoint = self.locate_entrypoint(root)
|
|
135
|
+
|
|
136
|
+
if self.shadows_path and not keep_bundled:
|
|
137
|
+
self._collapse_bundled(root, log=log)
|
|
138
|
+
|
|
139
|
+
freed = disk.prune_build_artifacts(root)
|
|
140
|
+
if freed:
|
|
141
|
+
log(f" removed the build tree ({disk.human(freed)})")
|
|
142
|
+
|
|
143
|
+
manifest.record_predictor(
|
|
144
|
+
self.name,
|
|
145
|
+
ref=ref,
|
|
146
|
+
commit=commit,
|
|
147
|
+
source=self.repo,
|
|
148
|
+
root=root,
|
|
149
|
+
entrypoint=str(entrypoint),
|
|
150
|
+
notes=self.install_notes,
|
|
151
|
+
interpreter=interpreters.describe(self.venv_python(root) or interpreter),
|
|
152
|
+
)
|
|
153
|
+
log(f" {self.name} ready at commit {commit[:10]}")
|
|
154
|
+
return root
|
|
155
|
+
|
|
156
|
+
install_notes: str = ""
|
|
157
|
+
|
|
158
|
+
def _collapse_bundled(self, root: Path, *, log=print) -> None:
|
|
159
|
+
"""Link helpers that would otherwise shadow the shared binary directory."""
|
|
160
|
+
from .. import deps
|
|
161
|
+
|
|
162
|
+
bundled = [b for b in deps.find_bundled({self.name: root}) if not b.linked]
|
|
163
|
+
if not bundled:
|
|
164
|
+
return
|
|
165
|
+
unrunnable = [b for b in bundled if b.runnable is False]
|
|
166
|
+
if unrunnable:
|
|
167
|
+
names = ", ".join(sorted({b.name for b in unrunnable}))
|
|
168
|
+
log(f" bundled {names} cannot run on this machine; using the shared copies")
|
|
169
|
+
else:
|
|
170
|
+
log(f" linking {len(bundled)} bundled binaries to the shared bin")
|
|
171
|
+
deps.link_bundled(bundled, log=lambda message: log(f" {message.strip()}"))
|
|
172
|
+
|
|
173
|
+
def post_install(self, root: Path, *, interpreter: Path | None = None, log=print) -> None:
|
|
174
|
+
"""Hook for tools needing a build step or Python dependencies."""
|
|
175
|
+
|
|
176
|
+
def leftovers(self, root: Path) -> list[tuple[Path, str]]:
|
|
177
|
+
"""Return unused files or directories with their cleanup descriptions."""
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
def select_interpreter(self, explicit: Path | str | None = None, *, log=print) -> Path:
|
|
181
|
+
"""The interpreter this predictor's private environment is built from."""
|
|
182
|
+
chosen = interpreters.find(
|
|
183
|
+
self.python_requires,
|
|
184
|
+
explicit=explicit,
|
|
185
|
+
prefer_conda=self.prefers_conda_interpreter,
|
|
186
|
+
)
|
|
187
|
+
if self.python_requires or self.prefers_conda_interpreter:
|
|
188
|
+
log(f" interpreter: {interpreters.describe(chosen)}")
|
|
189
|
+
return chosen
|
|
190
|
+
|
|
191
|
+
def install_to_prefix(
|
|
192
|
+
self,
|
|
193
|
+
root: Path,
|
|
194
|
+
interpreter: Path,
|
|
195
|
+
*,
|
|
196
|
+
log=print,
|
|
197
|
+
) -> Path:
|
|
198
|
+
"""Install source into a private pip target while retaining the selected interpreter."""
|
|
199
|
+
target = root / "site-packages"
|
|
200
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
log(f" installing into {target.name}/ with pip (this compiles extensions)")
|
|
202
|
+
shell.run(
|
|
203
|
+
[str(interpreter), "-m", "pip", "install", "--upgrade", "--target", str(target), "."],
|
|
204
|
+
cwd=root,
|
|
205
|
+
log=root / "install.log",
|
|
206
|
+
)
|
|
207
|
+
(root / "interpreter.txt").write_text(f"{interpreter}\n")
|
|
208
|
+
return target
|
|
209
|
+
|
|
210
|
+
def prefix_interpreter(self, root: Path) -> Path | None:
|
|
211
|
+
record = root / "interpreter.txt"
|
|
212
|
+
if not record.exists():
|
|
213
|
+
return None
|
|
214
|
+
path = Path(record.read_text().strip())
|
|
215
|
+
return path if path.exists() else None
|
|
216
|
+
|
|
217
|
+
def venv_python(self, root: Path) -> Path | None:
|
|
218
|
+
candidate = root / ".venv" / "bin" / "python"
|
|
219
|
+
return candidate if candidate.exists() else None
|
|
220
|
+
|
|
221
|
+
def build_venv(
|
|
222
|
+
self,
|
|
223
|
+
root: Path,
|
|
224
|
+
interpreter: Path | None,
|
|
225
|
+
requirements: Sequence[str] = (),
|
|
226
|
+
*,
|
|
227
|
+
editable_source: bool = False,
|
|
228
|
+
log=print,
|
|
229
|
+
) -> Path:
|
|
230
|
+
"""Create a private virtualenv and install dependencies with pip."""
|
|
231
|
+
venv = root / ".venv"
|
|
232
|
+
python = interpreter or Path(self.python())
|
|
233
|
+
if not (venv / "bin" / "python").exists():
|
|
234
|
+
log(f" creating private virtualenv with {python}")
|
|
235
|
+
shell.run([str(python), "-m", "venv", str(venv)])
|
|
236
|
+
|
|
237
|
+
pip = venv / "bin" / "pip"
|
|
238
|
+
shell.run(
|
|
239
|
+
[str(pip), "install", "--upgrade", "pip", "wheel", "setuptools"],
|
|
240
|
+
log=root / "install-pip.log",
|
|
241
|
+
)
|
|
242
|
+
if requirements:
|
|
243
|
+
log(f" installing {', '.join(requirements)}")
|
|
244
|
+
shell.run([str(pip), "install", *requirements], cwd=root, log=root / "install.log")
|
|
245
|
+
if editable_source:
|
|
246
|
+
log(" building the predictor from source (this compiles extensions)")
|
|
247
|
+
shell.run([str(pip), "install", "."], cwd=root, log=root / "install.log")
|
|
248
|
+
return venv / "bin" / "python"
|
|
249
|
+
|
|
250
|
+
def _resolve_commit(self, root: Path) -> str:
|
|
251
|
+
try:
|
|
252
|
+
result = shell.run(["git", "-C", str(root), "rev-parse", "HEAD"], check=False)
|
|
253
|
+
return (result.stdout or "").strip() or "unknown"
|
|
254
|
+
except OSError:
|
|
255
|
+
return "unknown"
|
|
256
|
+
|
|
257
|
+
def locate_entrypoint(self, root: Path) -> Path:
|
|
258
|
+
for candidate in self.entrypoint_candidates:
|
|
259
|
+
matches = sorted(root.glob(candidate))
|
|
260
|
+
for match in matches:
|
|
261
|
+
if match.is_file():
|
|
262
|
+
return match
|
|
263
|
+
listing = ", ".join(sorted(p.name for p in root.iterdir())[:12])
|
|
264
|
+
raise AdapterError(
|
|
265
|
+
f"{self.name}: no entrypoint found under {root}. "
|
|
266
|
+
f"Tried {list(self.entrypoint_candidates)}. Top level contains: {listing}. "
|
|
267
|
+
f"The upstream layout may have changed; pin a different --ref."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# ---- readiness ----------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def installed_root(self) -> Path:
|
|
273
|
+
record = manifest.predictor(self.name)
|
|
274
|
+
if not record:
|
|
275
|
+
raise NotInstalled(
|
|
276
|
+
f"{self.name} is not installed; run: suppOrth install --tools {self.name}"
|
|
277
|
+
)
|
|
278
|
+
root = Path(record["root"])
|
|
279
|
+
if not root.exists():
|
|
280
|
+
raise NotInstalled(
|
|
281
|
+
f"{self.name} is recorded at {root} but that path is gone; "
|
|
282
|
+
f"re-run suppOrth install --force"
|
|
283
|
+
)
|
|
284
|
+
return root
|
|
285
|
+
|
|
286
|
+
def extra_binaries(self, options: list[str] | None = None) -> tuple[str, ...]:
|
|
287
|
+
"""Binaries required only because of the options this run was given.
|
|
288
|
+
|
|
289
|
+
Alternative aligners and tree builders are not needed by every
|
|
290
|
+
configuration, so they are resolved from the run's options rather than
|
|
291
|
+
demanded up front.
|
|
292
|
+
"""
|
|
293
|
+
return ()
|
|
294
|
+
|
|
295
|
+
def check(self, options: list[str] | None = None) -> tuple[bool, str]:
|
|
296
|
+
"""Cheap confirmation that the tool starts, without running an analysis."""
|
|
297
|
+
try:
|
|
298
|
+
root = self.installed_root()
|
|
299
|
+
except NotInstalled as error:
|
|
300
|
+
return False, str(error)
|
|
301
|
+
try:
|
|
302
|
+
entrypoint = self.locate_entrypoint(root)
|
|
303
|
+
except AdapterError as error:
|
|
304
|
+
return False, str(error)
|
|
305
|
+
|
|
306
|
+
required = (*self.binaries, *self.extra_binaries(options))
|
|
307
|
+
missing = [b for b in dict.fromkeys(required) if shell.which(b) is None]
|
|
308
|
+
if missing:
|
|
309
|
+
return False, (
|
|
310
|
+
f"missing shared binaries: {', '.join(missing)} "
|
|
311
|
+
f"(suppOrth install --binaries {','.join(missing)})"
|
|
312
|
+
)
|
|
313
|
+
return True, f"ok ({entrypoint.name})"
|
|
314
|
+
|
|
315
|
+
# ---- execution ----------------------------------------------------
|
|
316
|
+
|
|
317
|
+
@abstractmethod
|
|
318
|
+
def run(self, context: StageContext) -> Path:
|
|
319
|
+
"""Execute the predictor and return the root of its native output."""
|
|
320
|
+
|
|
321
|
+
@abstractmethod
|
|
322
|
+
def parse(self, native_root: Path, proteomes: Sequence[Proteome] | Proteome, *rest: Proteome) -> PredictorResult:
|
|
323
|
+
"""Convert native output into canonical oriented pairs."""
|
|
324
|
+
|
|
325
|
+
@abstractmethod
|
|
326
|
+
def method_profile(self, context: StageContext) -> MethodProfile:
|
|
327
|
+
"""Search, tree and clustering choices used for this stage."""
|
|
328
|
+
|
|
329
|
+
def python(self) -> str:
|
|
330
|
+
import sys
|
|
331
|
+
|
|
332
|
+
return sys.executable
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Run Broccoli in its own working directory and read the step-4 pair tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..canonical import PredictorResult, build_result
|
|
9
|
+
from ..proteomes import Proteome
|
|
10
|
+
from ..method_stack import BROCCOLI
|
|
11
|
+
from .. import shell
|
|
12
|
+
from .base import Adapter, AdapterError, MethodProfile, StageContext
|
|
13
|
+
from .tabular import id_sets, pairs_from_table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Broccoli(Adapter):
|
|
17
|
+
name = "broccoli"
|
|
18
|
+
label = "B"
|
|
19
|
+
repo = "https://github.com/rderelle/Broccoli.git"
|
|
20
|
+
default_ref = "v1.4.0"
|
|
21
|
+
entrypoint_candidates = ("broccoli.py", "*/broccoli.py")
|
|
22
|
+
binaries = ("diamond", "FastTree")
|
|
23
|
+
install_notes = (
|
|
24
|
+
"run as a script from a per-stage working directory; "
|
|
25
|
+
"needs diamond and FastTree from the shared bin, plus ete3"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def post_install(self, root: Path, *, interpreter: Path | None = None, log=print) -> None:
|
|
29
|
+
self.build_venv(root, interpreter, ("ete3", "six", "numpy"), log=log)
|
|
30
|
+
|
|
31
|
+
def _interpreter(self, root: Path) -> str:
|
|
32
|
+
return str(self.venv_python(root) or self.python())
|
|
33
|
+
|
|
34
|
+
def method_profile(self, context: StageContext) -> MethodProfile:
|
|
35
|
+
return MethodProfile(
|
|
36
|
+
search="DIAMOND (via Broccoli)",
|
|
37
|
+
clustering=BROCCOLI.clustering,
|
|
38
|
+
alignment=BROCCOLI.alignment,
|
|
39
|
+
tree="FastTree, per-orthogroup",
|
|
40
|
+
orthology=BROCCOLI.orthology,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def run(self, context: StageContext) -> Path:
|
|
44
|
+
root = self.installed_root()
|
|
45
|
+
entrypoint = self.locate_entrypoint(root)
|
|
46
|
+
|
|
47
|
+
output = context.work_dir / "results"
|
|
48
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
argv = [
|
|
51
|
+
self._interpreter(root),
|
|
52
|
+
str(entrypoint),
|
|
53
|
+
"-dir",
|
|
54
|
+
str(context.input_dir),
|
|
55
|
+
"-threads",
|
|
56
|
+
str(context.threads),
|
|
57
|
+
*context.options,
|
|
58
|
+
]
|
|
59
|
+
shell.run(argv, cwd=output, log=context.log, timeout=None, threads=context.threads)
|
|
60
|
+
return output
|
|
61
|
+
|
|
62
|
+
def parse(self, native_root: Path, proteomes: Sequence[Proteome] | Proteome, *rest: Proteome) -> PredictorResult:
|
|
63
|
+
loaded = (proteomes, *rest) if isinstance(proteomes, Proteome) else tuple(proteomes)
|
|
64
|
+
tables = self._result_tables(native_root)
|
|
65
|
+
if not tables:
|
|
66
|
+
raise AdapterError(
|
|
67
|
+
f"Broccoli produced no pair table under {native_root}. "
|
|
68
|
+
f"Expected dir_step4/orthologous_pairs.txt; check the stage log."
|
|
69
|
+
)
|
|
70
|
+
raw: list[tuple[str, str]] = []
|
|
71
|
+
for table in tables:
|
|
72
|
+
raw.extend(pairs_from_table(table, *id_sets(loaded)))
|
|
73
|
+
return build_result(self.name, self.label, raw, loaded, source_files=tables)
|
|
74
|
+
|
|
75
|
+
def _result_tables(self, native_root: Path) -> list[Path]:
|
|
76
|
+
pairs = [p for p in native_root.rglob("*orthologous_pairs*") if p.is_file()]
|
|
77
|
+
if pairs:
|
|
78
|
+
return pairs
|
|
79
|
+
return [p for p in native_root.rglob("*orthologous_groups*") if p.is_file()]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Register OrthoFinder search commands with a thread allocation based on the number of proteomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
PREFIX = "supporth_"
|
|
13
|
+
|
|
14
|
+
# Mirrors of the stock entries, differing only in the thread count. Keeping the
|
|
15
|
+
# rest of each command identical means results stay comparable to a stock run.
|
|
16
|
+
SEARCH_TEMPLATES: dict[str, dict[str, str]] = {
|
|
17
|
+
"diamond": {
|
|
18
|
+
"db_cmd": "diamond makedb --ignore-warnings --in INPUT -d OUTPUT",
|
|
19
|
+
"search_cmd": (
|
|
20
|
+
"diamond blastp --ignore-warnings -d DATABASE -q INPUT -o OUTPUT "
|
|
21
|
+
"--more-sensitive -p {threads} --quiet -e 0.001 --compress 1"
|
|
22
|
+
),
|
|
23
|
+
},
|
|
24
|
+
"diamond_ultra_sens": {
|
|
25
|
+
"db_cmd": "diamond makedb --ignore-warnings --in INPUT -d OUTPUT",
|
|
26
|
+
"search_cmd": (
|
|
27
|
+
"diamond blastp --ignore-warnings -d DATABASE -q INPUT -o OUTPUT "
|
|
28
|
+
"--ultra-sensitive -p {threads} --quiet -e 0.001 --compress 1"
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
"blast": {
|
|
32
|
+
"db_cmd": "makeblastdb -dbtype prot -in INPUT -out OUTPUT",
|
|
33
|
+
"search_cmd": (
|
|
34
|
+
"blastp -outfmt 6 -evalue 0.001 -num_threads {threads} "
|
|
35
|
+
"-query INPUT -db DATABASE -out OUTPUT"
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
"mmseqs": {
|
|
39
|
+
"db_cmd": "mmseqs createdb INPUT OUTPUT.fa ; mmseqs createindex OUTPUT.fa /tmp",
|
|
40
|
+
"search_cmd": (
|
|
41
|
+
"mmseqs search PATH/mmseqsDBBASENAME DATABASE.fa OUTPUT.db /tmp/tmpBASEOUTNAME "
|
|
42
|
+
"--threads {threads} ; mmseqs convertalis --threads {threads} "
|
|
43
|
+
"PATH/mmseqsDBBASENAME DATABASE.fa OUTPUT.db OUTPUT"
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
SEARCH_BINARIES: dict[str, tuple[str, ...]] = {
|
|
49
|
+
"diamond": ("diamond",),
|
|
50
|
+
"diamond_ultra_sens": ("diamond",),
|
|
51
|
+
"blast": ("blastp", "makeblastdb"),
|
|
52
|
+
"mmseqs": ("mmseqs",),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def search_job_count(n_species: int = 2, *, double_blast: bool = True) -> int:
|
|
57
|
+
"""Number of all-versus-all search jobs OrthoFinder will create.
|
|
58
|
+
|
|
59
|
+
With ``double_blast`` (the default) both directions of every species pair
|
|
60
|
+
are searched, including self-comparisons, giving ``n**2`` jobs. The ``-1``
|
|
61
|
+
option searches one direction only, giving ``n * (n + 1) / 2``.
|
|
62
|
+
"""
|
|
63
|
+
if double_blast:
|
|
64
|
+
return n_species * n_species
|
|
65
|
+
return n_species * (n_species + 1) // 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def threads_per_job(total_threads: int, jobs: int) -> int:
|
|
69
|
+
"""Allocate threads among search jobs, with at least one thread per job."""
|
|
70
|
+
return max(1, total_threads // max(1, jobs))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_config(root: Path) -> Path | None:
|
|
74
|
+
"""Locate config.json inside an OrthoFinder source tree."""
|
|
75
|
+
preferred = root / "scripts_of" / "config.json"
|
|
76
|
+
if preferred.is_file():
|
|
77
|
+
return preferred
|
|
78
|
+
for candidate in sorted(root.rglob("config.json")):
|
|
79
|
+
if candidate.is_file():
|
|
80
|
+
return candidate
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def variant_name(program: str) -> str:
|
|
85
|
+
return f"{PREFIX}{program}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def binaries_for_search(program: str) -> tuple[str, ...]:
|
|
89
|
+
base = program[len(PREFIX):] if program.startswith(PREFIX) else program
|
|
90
|
+
return SEARCH_BINARIES.get(base, ())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def config_lock(root: Path) -> Iterator[None]:
|
|
95
|
+
"""Serialise access to one OrthoFinder install.
|
|
96
|
+
|
|
97
|
+
The thread count is baked into config.json, so two SuppOrth runs sharing an
|
|
98
|
+
install must not configure it at the same time.
|
|
99
|
+
"""
|
|
100
|
+
lock_path = root / ".supporth.lock"
|
|
101
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
with lock_path.open("w") as handle:
|
|
103
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
finally:
|
|
107
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def register_threaded_search(config_path: Path, program: str, threads: int) -> str:
|
|
111
|
+
"""Register a threaded variant, or preserve unrecognised search methods."""
|
|
112
|
+
base = program[len(PREFIX):] if program.startswith(PREFIX) else program
|
|
113
|
+
template = SEARCH_TEMPLATES.get(base)
|
|
114
|
+
if template is None:
|
|
115
|
+
return program
|
|
116
|
+
|
|
117
|
+
name = variant_name(base)
|
|
118
|
+
backup = config_path.with_suffix(".json.supporth-orig")
|
|
119
|
+
if not backup.exists():
|
|
120
|
+
shutil.copy2(config_path, backup)
|
|
121
|
+
|
|
122
|
+
with config_path.open() as handle:
|
|
123
|
+
config = json.load(handle)
|
|
124
|
+
|
|
125
|
+
config[name] = {
|
|
126
|
+
"program_type": "search",
|
|
127
|
+
"db_cmd": template["db_cmd"],
|
|
128
|
+
"search_cmd": template["search_cmd"].format(threads=threads),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
temporary = config_path.with_suffix(".json.supporth-tmp")
|
|
132
|
+
with temporary.open("w") as handle:
|
|
133
|
+
json.dump(config, handle, indent=4)
|
|
134
|
+
handle.write("\n")
|
|
135
|
+
temporary.replace(config_path)
|
|
136
|
+
return name
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def strip_option(options: list[str], flag: str) -> list[str]:
|
|
140
|
+
"""Remove a flag and its value from pass-through options."""
|
|
141
|
+
out: list[str] = []
|
|
142
|
+
skip = False
|
|
143
|
+
for token in options:
|
|
144
|
+
if skip:
|
|
145
|
+
skip = False
|
|
146
|
+
continue
|
|
147
|
+
if token == flag:
|
|
148
|
+
skip = True
|
|
149
|
+
continue
|
|
150
|
+
out.append(token)
|
|
151
|
+
return out
|