bindsight 0.3.5__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.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,217 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Colab notebook generator for the GPU design+validation job.
4
+
5
+ The notebook is a thin wrapper over the real executor
6
+ (:mod:`bindsight.runners.job_exec`) — the SAME code the Modal / local-Docker /
7
+ Kaggle backends run — so there is exactly one source of truth for the pipeline.
8
+ It:
9
+
10
+ 1. confirms a GPU,
11
+ 2. installs the pinned tools (RFdiffusion + ProteinMPNN + Boltz-2, revisions
12
+ from :mod:`bindsight.runners.tools`) and bindsight itself,
13
+ 3. materialises the design spec + target structure (embedded as base64),
14
+ 4. runs ``python -m bindsight.runners.job_exec`` (real RFdiffusion → ProteinMPNN
15
+ → Boltz-2),
16
+ 5. packages the results tarball for download.
17
+
18
+ Because the notebook calls the executor rather than re-implementing the tool
19
+ invocations, the contig/hotspot construction, FASTA parsing, and Boltz output
20
+ parsing are all the (tested) functions in ``bindsight.runners.tools`` — no
21
+ divergent copy lives here.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from bindsight.runners import tools
31
+ from bindsight.runners.notebook import (
32
+ build_notebook,
33
+ code_cell_from_template,
34
+ markdown_cell,
35
+ write_notebook,
36
+ )
37
+
38
+
39
+ def build_design_notebook(
40
+ *,
41
+ handle_id: str,
42
+ designer: str,
43
+ gpu_type: str,
44
+ spec: dict[str, Any],
45
+ ) -> dict[str, Any]:
46
+ """Build the design + validation notebook for a single GPU job.
47
+
48
+ Args:
49
+ handle_id: stable ID; results tarball is ``<handle_id>.tar.gz``.
50
+ designer: ``rfdiff_mpnn`` | ``bindcraft`` | ``boltzgen``.
51
+ gpu_type: ``T4`` (free Colab) or ``A100`` (Pro+/Modal).
52
+ spec: design spec dict (``DesignSpec.model_dump()``); include
53
+ ``target_structure_b64`` so the target is materialised on the GPU.
54
+
55
+ Returns:
56
+ Jupyter v4 notebook dict ready for serialisation.
57
+ """
58
+ spec_json = json.dumps(spec, indent=2)
59
+ cells = [
60
+ markdown_cell(_intro(handle_id, designer, gpu_type)),
61
+ code_cell_from_template(_GPU_CHECK_CELL, {}),
62
+ markdown_cell(
63
+ "## 1. Install RFdiffusion + ProteinMPNN + Boltz-2 + bindsight\n\n"
64
+ "Pinned upstream revisions (~5–10 min first run, cached afterwards)."
65
+ ),
66
+ code_cell_from_template(
67
+ _INSTALL_CELL,
68
+ {
69
+ "rfdiff_repo": tools.RFDIFF_REPO,
70
+ "rfdiff_commit": tools.RFDIFF_COMMIT,
71
+ "mpnn_repo": tools.PROTEINMPNN_REPO,
72
+ "mpnn_commit": tools.PROTEINMPNN_COMMIT,
73
+ "boltz_pip": tools.BOLTZ_PIP,
74
+ "base_url": tools.RFDIFF_WEIGHTS["Base_ckpt.pt"],
75
+ "complex_url": tools.RFDIFF_WEIGHTS["Complex_base_ckpt.pt"],
76
+ },
77
+ ),
78
+ markdown_cell("## 2. Load the design spec + target structure"),
79
+ code_cell_from_template(
80
+ _LOAD_SPEC_CELL,
81
+ {"handle_id": handle_id, "spec_json": spec_json.replace("\\", "\\\\")},
82
+ ),
83
+ markdown_cell(
84
+ "## 3. Run the design + validation pipeline\n\n"
85
+ "This calls `bindsight.runners.job_exec` — RFdiffusion (backbones) → "
86
+ "ProteinMPNN (sequences) → Boltz-2 (structure + affinity) — the exact "
87
+ "code the Modal/local backends run."
88
+ ),
89
+ code_cell_from_template(_RUN_CELL, {"handle_id": handle_id}),
90
+ markdown_cell(
91
+ "## 4. Download results\n\nDownload "
92
+ f"`{handle_id}.tar.gz` and drop it into your local `<run_dir>/design/`."
93
+ ),
94
+ code_cell_from_template(_DOWNLOAD_CELL, {"handle_id": handle_id}),
95
+ ]
96
+ return build_notebook(
97
+ cells=cells,
98
+ gpu=gpu_type if gpu_type in {"T4", "L4", "A100", "V100"} else "T4", # type: ignore[arg-type]
99
+ title=f"bindsight {designer} {handle_id[:8]}",
100
+ )
101
+
102
+
103
+ def write_design_notebook(
104
+ out_path: Path,
105
+ *,
106
+ handle_id: str,
107
+ designer: str,
108
+ gpu_type: str,
109
+ spec: dict[str, Any],
110
+ ) -> Path:
111
+ """Build and write the design notebook to ``out_path``."""
112
+ notebook = build_design_notebook(
113
+ handle_id=handle_id, designer=designer, gpu_type=gpu_type, spec=spec
114
+ )
115
+ return write_notebook(notebook, out_path)
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Cell content
120
+ # ---------------------------------------------------------------------------
121
+ def _intro(handle_id: str, designer: str, gpu_type: str) -> str:
122
+ return (
123
+ f"# bindsight design + validate — `{handle_id}`\n\n"
124
+ f"**Designer:** `{designer}` · **GPU:** `{gpu_type}`\n\n"
125
+ "Runtime → Run all. This notebook wraps `bindsight.runners.job_exec` (the "
126
+ "same executor the Modal / local-Docker / Kaggle backends use), so the "
127
+ "design+validation pipeline here is identical to the headless one.\n\n"
128
+ "Upstream tools:\n"
129
+ f"- RFdiffusion {tools.RFDIFF_REPO} @ `{tools.RFDIFF_COMMIT[:10]}`\n"
130
+ f"- ProteinMPNN {tools.PROTEINMPNN_REPO} @ `{tools.PROTEINMPNN_COMMIT[:10]}`\n"
131
+ "- Boltz-2 https://github.com/jwohlwend/boltz\n"
132
+ )
133
+
134
+
135
+ _GPU_CHECK_CELL = """\
136
+ # Confirm a CUDA GPU is available — RFdiffusion and Boltz-2 both require it.
137
+ import subprocess, sys
138
+ try:
139
+ info = subprocess.check_output(['nvidia-smi'], text=True)
140
+ print(info.split('\\n')[8]) # GPU model line
141
+ print('GPU OK ✓')
142
+ except (FileNotFoundError, subprocess.CalledProcessError):
143
+ print('No GPU. Runtime → Change runtime type → T4 GPU.', file=sys.stderr)
144
+ raise SystemExit(1)
145
+ """
146
+
147
+ _INSTALL_CELL = """\
148
+ # Install the pinned design tools + bindsight, then point bindsight at them via
149
+ # BINDSIGHT_TOOLS_ROOT so the executor reuses them instead of re-cloning.
150
+ import os, pathlib, subprocess
151
+ ROOT = pathlib.Path('/content/bindsight_tools'); ROOT.mkdir(exist_ok=True)
152
+ os.environ['BINDSIGHT_TOOLS_ROOT'] = str(ROOT)
153
+
154
+ def sh(*cmd): subprocess.run(list(cmd), check=True)
155
+
156
+ # RFdiffusion (https://github.com/RosettaCommons/RFdiffusion)
157
+ rfdiff = ROOT / 'RFdiffusion'
158
+ if not rfdiff.exists():
159
+ sh('git', 'clone', '-q', '{{ rfdiff_repo }}', str(rfdiff))
160
+ sh('git', '-C', str(rfdiff), 'checkout', '-q', '{{ rfdiff_commit }}')
161
+ models = rfdiff / 'models'; models.mkdir(exist_ok=True)
162
+ for url, name in [('{{ base_url }}', 'Base_ckpt.pt'), ('{{ complex_url }}', 'Complex_base_ckpt.pt')]:
163
+ if not (models / name).exists():
164
+ sh('wget', '-q', url, '-O', str(models / name))
165
+ sh('pip', 'install', '-q', '-r', str(rfdiff / 'env/SE3Transformer/requirements.txt'))
166
+ sh('pip', 'install', '-q', '-e', str(rfdiff))
167
+
168
+ # ProteinMPNN (https://github.com/dauparas/ProteinMPNN)
169
+ mpnn = ROOT / 'ProteinMPNN'
170
+ if not mpnn.exists():
171
+ sh('git', 'clone', '-q', '{{ mpnn_repo }}', str(mpnn))
172
+ sh('git', '-C', str(mpnn), 'checkout', '-q', '{{ mpnn_commit }}')
173
+
174
+ # Boltz-2 + bindsight (bindsight is not on PyPI yet — install from GitHub)
175
+ sh('pip', 'install', '-q', '{{ boltz_pip }}', 'git+https://github.com/mikhaeelatefrizk/bindsight.git')
176
+ print('tools installed ✓')
177
+ """
178
+
179
+ _LOAD_SPEC_CELL = """\
180
+ # Write the design spec + target structure the executor will read.
181
+ import base64, json, pathlib
182
+ JOB_ID = '{{ handle_id }}'
183
+ SPEC = json.loads('''{{ spec_json }}''')
184
+ spec_dir = pathlib.Path(f'/content/{JOB_ID}_spec'); spec_dir.mkdir(exist_ok=True)
185
+ (spec_dir / 'spec.json').write_text(json.dumps(SPEC, indent=2))
186
+ name = SPEC.get('extra_params', {}).get('target_structure_name', 'target.pdb')
187
+ if 'target_structure_b64' in SPEC:
188
+ (spec_dir / name).write_bytes(base64.b64decode(SPEC['target_structure_b64']))
189
+ print('target structure written:', name)
190
+ else:
191
+ print('WARNING: no target_structure_b64 in spec; upload', name, 'into', spec_dir)
192
+ print('target:', SPEC.get('target_uniprot'), '| residues:', SPEC.get('epitope_residues'))
193
+ """
194
+
195
+ _RUN_CELL = """\
196
+ # Run the real RFdiffusion → ProteinMPNN → Boltz-2 pipeline via the executor.
197
+ import subprocess, sys
198
+ JOB_ID = '{{ handle_id }}'
199
+ subprocess.run(
200
+ [sys.executable, '-m', 'bindsight.runners.job_exec',
201
+ f'/content/{JOB_ID}_spec/spec.json', f'/content/{JOB_ID}.tar.gz'],
202
+ check=True,
203
+ )
204
+ print('done -> /content/{{ handle_id }}.tar.gz')
205
+ """
206
+
207
+ _DOWNLOAD_CELL = """\
208
+ # Download the results tarball; drop it into <run_dir>/design/ on your machine.
209
+ import pathlib
210
+ tarball = pathlib.Path('/content/{{ handle_id }}.tar.gz')
211
+ print(f'{tarball} ({tarball.stat().st_size / 1e6:.1f} MB)' if tarball.exists() else 'no tarball produced')
212
+ try:
213
+ from google.colab import files
214
+ files.download(str(tarball))
215
+ except ImportError:
216
+ pass # not on Colab — download manually from the Files panel
217
+ """
@@ -0,0 +1,81 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """GPU runner protocol.
4
+
5
+ Runners abstract WHERE GPU work executes. The pipeline calls the same
6
+ ``submit/poll/fetch`` API regardless of whether the backend is free Colab,
7
+ paid Modal, Kaggle Notebooks, local NVIDIA Docker, or a mock for CI.
8
+
9
+ The ``estimate_cost`` method gates ``--dry-run``: it must return a usable
10
+ estimate without launching anything.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Literal, Protocol, runtime_checkable
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ JobState = Literal["queued", "running", "succeeded", "failed", "cancelled"]
21
+
22
+
23
+ class JobHandle(BaseModel):
24
+ """Opaque handle returned by :meth:`GPURunner.submit`."""
25
+
26
+ model_config = ConfigDict(extra="allow", frozen=True)
27
+
28
+ backend: str
29
+ id: str
30
+ submitted_at: str
31
+
32
+
33
+ class JobStatus(BaseModel):
34
+ """Status snapshot returned by :meth:`GPURunner.poll`."""
35
+
36
+ model_config = ConfigDict(extra="forbid", frozen=True)
37
+
38
+ handle: JobHandle
39
+ state: JobState
40
+ progress: float | None = Field(None, ge=0.0, le=1.0)
41
+ log_tail: str | None = None
42
+
43
+
44
+ class CostEstimate(BaseModel):
45
+ """Pre-flight cost estimate for a job."""
46
+
47
+ model_config = ConfigDict(extra="forbid", frozen=True)
48
+
49
+ backend: str
50
+ gpu_type: str = Field(..., description="e.g. 'T4', 'A100-40GB', 'A100-80GB'.")
51
+ gpu_hours: float = Field(..., ge=0.0)
52
+ usd_estimate: float | None = Field(
53
+ None,
54
+ ge=0.0,
55
+ description="Best-effort USD estimate. None for free tiers (Colab/Kaggle).",
56
+ )
57
+ queue_minutes_estimate: float | None = Field(None, ge=0.0)
58
+ notes: str | None = None
59
+
60
+
61
+ @runtime_checkable
62
+ class GPURunner(Protocol):
63
+ """Protocol every runner backend must implement."""
64
+
65
+ name: str
66
+
67
+ def estimate_cost(self, spec_size: int) -> CostEstimate:
68
+ """Estimate cost for a job of the given spec_size (e.g. trajectory count)."""
69
+ ...
70
+
71
+ def submit(self, spec_path: Path, *, results_dir: Path) -> JobHandle:
72
+ """Launch the job; return immediately with a handle."""
73
+ ...
74
+
75
+ def poll(self, handle: JobHandle) -> JobStatus:
76
+ """Return current job status; cheap, may be called frequently."""
77
+ ...
78
+
79
+ def fetch(self, handle: JobHandle) -> Path:
80
+ """Block until job completes, then return the local results.tar.gz path."""
81
+ ...
@@ -0,0 +1,104 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Build a wheel from the working tree, so a GPU run executes the code you have.
4
+
5
+ Remote backends install bindsight with pip. Until now that meant
6
+ ``pip install git+<repo>`` with no ref, so every Kaggle run installed the
7
+ default branch — whatever the operator had checked out, whatever was fixed
8
+ locally, whatever was on an unmerged branch. A run launched to validate a fix
9
+ ran the unfixed code and reported success.
10
+
11
+ Pinning a ref narrows that but does not close it: a branch name still resolves
12
+ on the GPU, at pip time, to whatever it points at then, and an unpushed commit
13
+ cannot be named at all. A wheel built from the tree that launched the run is the
14
+ only thing that answers "which code produced this result" exactly.
15
+
16
+ The wheel is small because bindsight is pure Python, so it can travel inside the
17
+ kernel script itself rather than needing a dataset upload.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ LOG = logging.getLogger(__name__)
28
+
29
+
30
+ def find_repo_root(start: Path | None = None) -> Path | None:
31
+ """Walk up from ``start`` looking for the directory holding ``pyproject.toml``.
32
+
33
+ Args:
34
+ start: where to begin; defaults to this module's location.
35
+
36
+ Returns:
37
+ The source-checkout root, or None when running from an installed
38
+ package with no source tree beside it.
39
+ """
40
+ here = (start or Path(__file__).resolve()).resolve()
41
+ for candidate in (here, *here.parents):
42
+ if (candidate / "pyproject.toml").is_file() and (candidate / "bindsight").is_dir():
43
+ return candidate
44
+ return None
45
+
46
+
47
+ def build_working_tree_wheel(dest_dir: Path, *, repo_root: Path | None = None) -> Path | None:
48
+ """Build a wheel from the source checkout and return its path.
49
+
50
+ Uses ``pip wheel --no-deps``, which honours the project's own build backend,
51
+ so the wheel contains exactly what an install of this tree would.
52
+
53
+ Args:
54
+ dest_dir: directory to write the wheel into.
55
+ repo_root: the checkout to build; discovered when omitted.
56
+
57
+ Returns:
58
+ Path to the built wheel, or None when there is no source tree to build
59
+ or the build failed. Returning None rather than raising is deliberate:
60
+ the caller falls back to a git ref and warns, so a user who installed
61
+ bindsight from PyPI can still submit a job.
62
+ """
63
+ root = repo_root or find_repo_root()
64
+ if root is None:
65
+ LOG.warning(
66
+ "no source checkout found, so no wheel can be built; the remote job will "
67
+ "install bindsight from git and may therefore run different code"
68
+ )
69
+ return None
70
+
71
+ dest_dir = Path(dest_dir)
72
+ dest_dir.mkdir(parents=True, exist_ok=True)
73
+ cmd = [
74
+ sys.executable,
75
+ "-m",
76
+ "pip",
77
+ "wheel",
78
+ str(root),
79
+ "--no-deps",
80
+ "--no-build-isolation",
81
+ "--wheel-dir",
82
+ str(dest_dir),
83
+ "--quiet",
84
+ ]
85
+ LOG.info("building a wheel from %s so the GPU runs this tree", root)
86
+ try:
87
+ subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=900)
88
+ except subprocess.CalledProcessError as e:
89
+ LOG.warning("wheel build failed (%s); falling back to a git install:\n%s", e, e.stderr)
90
+ return None
91
+ except (subprocess.TimeoutExpired, OSError) as e:
92
+ LOG.warning("wheel build could not run (%s); falling back to a git install", e)
93
+ return None
94
+
95
+ wheels = sorted(dest_dir.glob("bindsight-*.whl"), key=lambda p: p.stat().st_mtime)
96
+ if not wheels:
97
+ LOG.warning("wheel build produced nothing in %s; falling back to a git install", dest_dir)
98
+ return None
99
+ wheel = wheels[-1]
100
+ LOG.info("built %s (%d bytes)", wheel.name, wheel.stat().st_size)
101
+ return wheel
102
+
103
+
104
+ __all__ = ["build_working_tree_wheel", "find_repo_root"]