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 ADDED
@@ -0,0 +1,5 @@
1
+ """SuppOrth: support-based integration of complementary orthology predictors."""
2
+
3
+ __version__ = "0.4.0"
4
+
5
+ __all__ = ["__version__"]
@@ -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