code-mower 0.5.0b5__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 (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env python3
2
+ """Create and report the controlled Python environment for Code Mower tools.
3
+
4
+ This script is deliberately compatible with old ambient Python interpreters.
5
+ It exists so a checkout whose `python3` is 3.7, 3.8, or 3.9 can still find a
6
+ new enough interpreter, create `.code-mower-venv`, and run Code Mower tests from
7
+ that controlled environment.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
24
+
25
+
26
+ MIN_PYTHON = (3, 11)
27
+ DEFAULT_VENV = ".code-mower-venv"
28
+ DEFAULT_REQUIREMENTS = "requirements/requirements.txt"
29
+ PYTHON_ENV = "CODE_MOWER_PYTHON"
30
+ DEFAULT_LOCK_TIMEOUT_SECONDS = 120.0
31
+ DEFAULT_LOCK_POLL_SECONDS = 0.2
32
+ DEFAULT_LOCK_STALE_SECONDS = 30 * 60.0
33
+ LOCK_OWNER_FILE = "owner.json"
34
+
35
+ RunFn = Callable[..., subprocess.CompletedProcess]
36
+ WhichFn = Callable[[str], Optional[str]]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PythonInfo:
41
+ command: str
42
+ executable: str
43
+ version: tuple[int, int, int]
44
+
45
+ @property
46
+ def version_text(self) -> str:
47
+ return ".".join(str(part) for part in self.version)
48
+
49
+ @property
50
+ def supported(self) -> bool:
51
+ return self.version[:2] >= MIN_PYTHON
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class BootstrapResult:
56
+ repo_root: Path
57
+ venv_dir: Path
58
+ venv_python: Path
59
+ base_python: PythonInfo
60
+ venv_python_info: PythonInfo
61
+ requirements: Path
62
+ created: bool
63
+ installed: bool
64
+
65
+ def as_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "repo_root": str(self.repo_root),
68
+ "venv_dir": str(self.venv_dir),
69
+ "venv_python": str(self.venv_python),
70
+ "base_python": {
71
+ "command": self.base_python.command,
72
+ "executable": self.base_python.executable,
73
+ "version": self.base_python.version_text,
74
+ },
75
+ "venv_python_version": self.venv_python_info.version_text,
76
+ "requirements": str(self.requirements),
77
+ "created": self.created,
78
+ "installed": self.installed,
79
+ "min_python": ".".join(str(part) for part in MIN_PYTHON),
80
+ }
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class VenvLock:
85
+ lock_dir: Path
86
+ owner_id: str
87
+ stop_event: threading.Event
88
+ heartbeat_thread: threading.Thread
89
+
90
+
91
+ def _repo_root() -> Path:
92
+ return Path(__file__).resolve().parents[1]
93
+
94
+
95
+ def _run(
96
+ args: Sequence[str],
97
+ *,
98
+ runner: RunFn = subprocess.run,
99
+ capture_output: bool = False,
100
+ ) -> subprocess.CompletedProcess[str]:
101
+ return runner(
102
+ list(args),
103
+ capture_output=capture_output,
104
+ text=True,
105
+ check=False,
106
+ )
107
+
108
+
109
+ def probe_python(command: str, *, runner: RunFn = subprocess.run) -> PythonInfo:
110
+ code = (
111
+ "import json, sys; "
112
+ "print(json.dumps({'executable': sys.executable, "
113
+ "'version': list(sys.version_info[:3])}))"
114
+ )
115
+ result = _run(
116
+ [command, "-c", code],
117
+ runner=runner,
118
+ capture_output=True,
119
+ )
120
+ if result.returncode != 0:
121
+ detail = (result.stderr or result.stdout or "").strip()
122
+ raise RuntimeError(f"{command}: version probe failed: {detail}")
123
+ try:
124
+ payload = json.loads(result.stdout)
125
+ version = tuple(int(part) for part in payload["version"])
126
+ executable = str(payload["executable"])
127
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
128
+ raise RuntimeError(f"{command}: invalid version probe output") from exc
129
+ if len(version) != 3:
130
+ raise RuntimeError(f"{command}: invalid Python version tuple: {version!r}")
131
+ return PythonInfo(command=command, executable=executable, version=version)
132
+
133
+
134
+ def _resolve_candidate(command: str, *, which: WhichFn = shutil.which) -> str | None:
135
+ if os.sep in command or (os.altsep and os.altsep in command):
136
+ return command if Path(command).exists() else None
137
+ return which(command)
138
+
139
+
140
+ def _dedupe(values: Iterable[str]) -> list[str]:
141
+ seen: set[str] = set()
142
+ result: list[str] = []
143
+ for value in values:
144
+ if not value or value in seen:
145
+ continue
146
+ seen.add(value)
147
+ result.append(value)
148
+ return result
149
+
150
+
151
+ def candidate_commands(environ: Mapping[str, str] | None = None) -> tuple[str, ...]:
152
+ env = os.environ if environ is None else environ
153
+ explicit = env.get(PYTHON_ENV)
154
+ if explicit:
155
+ return (explicit,)
156
+ return tuple(
157
+ _dedupe(
158
+ (
159
+ sys.executable,
160
+ "python3.13",
161
+ "python3.12",
162
+ "python3.11",
163
+ "python3",
164
+ "/opt/homebrew/bin/python3",
165
+ "/usr/local/bin/python3",
166
+ "/usr/bin/python3",
167
+ )
168
+ )
169
+ )
170
+
171
+
172
+ def find_supported_python(
173
+ *,
174
+ environ: Mapping[str, str] | None = None,
175
+ candidates: Sequence[str] | None = None,
176
+ runner: RunFn = subprocess.run,
177
+ which: WhichFn = shutil.which,
178
+ ) -> PythonInfo:
179
+ env = os.environ if environ is None else environ
180
+ explicit = bool(env.get(PYTHON_ENV))
181
+ errors: list[str] = []
182
+ for command in candidates or candidate_commands(env):
183
+ resolved = _resolve_candidate(command, which=which)
184
+ if not resolved:
185
+ errors.append(f"{command}: not found")
186
+ continue
187
+ try:
188
+ info = probe_python(resolved, runner=runner)
189
+ except RuntimeError as exc:
190
+ errors.append(str(exc))
191
+ continue
192
+ if info.supported:
193
+ return info
194
+ errors.append(f"{resolved}: Python {info.version_text} is below 3.11")
195
+ if explicit:
196
+ break
197
+
198
+ hint = (
199
+ f"Set {PYTHON_ENV}=/path/to/python3.11+ or install Python 3.11+."
200
+ )
201
+ detail = "; ".join(errors[-6:]) if errors else "no candidates tried"
202
+ raise RuntimeError(f"no supported Python found ({detail}). {hint}")
203
+
204
+
205
+ def venv_python_path(venv_dir: Path) -> Path:
206
+ if os.name == "nt":
207
+ return venv_dir / "Scripts" / "python.exe"
208
+ return venv_dir / "bin" / "python"
209
+
210
+
211
+ def venv_lock_dir(venv_dir: Path) -> Path:
212
+ return venv_dir.parent / f"{venv_dir.name}.lock"
213
+
214
+
215
+ def _venv_lock_owner_path(lock_dir: Path) -> Path:
216
+ return lock_dir / LOCK_OWNER_FILE
217
+
218
+
219
+ def _acquire_venv_lock(
220
+ lock_dir: Path,
221
+ *,
222
+ timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
223
+ poll_seconds: float = DEFAULT_LOCK_POLL_SECONDS,
224
+ stale_seconds: float = DEFAULT_LOCK_STALE_SECONDS,
225
+ ) -> VenvLock:
226
+ lock_dir.parent.mkdir(parents=True, exist_ok=True)
227
+ started = time.monotonic()
228
+ while True:
229
+ try:
230
+ lock_dir.mkdir()
231
+ except FileExistsError:
232
+ if _remove_stale_venv_lock(lock_dir, stale_seconds=stale_seconds):
233
+ continue
234
+ if time.monotonic() - started >= timeout_seconds:
235
+ raise RuntimeError(
236
+ "timed out waiting for Code Mower venv lock: "
237
+ f"{lock_dir}"
238
+ )
239
+ time.sleep(poll_seconds)
240
+ continue
241
+ owner_id = f"{os.getpid()}-{uuid.uuid4().hex}"
242
+ try:
243
+ _venv_lock_owner_path(lock_dir).write_text(
244
+ json.dumps(
245
+ {
246
+ "owner_id": owner_id,
247
+ "pid": os.getpid(),
248
+ "created_at": time.time(),
249
+ },
250
+ sort_keys=True,
251
+ )
252
+ + "\n",
253
+ encoding="utf-8",
254
+ )
255
+ except OSError:
256
+ lock_dir.rmdir()
257
+ raise
258
+ return _start_venv_lock_heartbeat(
259
+ lock_dir,
260
+ owner_id=owner_id,
261
+ stale_seconds=stale_seconds,
262
+ )
263
+
264
+
265
+ def _start_venv_lock_heartbeat(
266
+ lock_dir: Path,
267
+ *,
268
+ owner_id: str,
269
+ stale_seconds: float,
270
+ ) -> VenvLock:
271
+ stop_event = threading.Event()
272
+ interval = 30.0
273
+ if stale_seconds > 0:
274
+ interval = max(DEFAULT_LOCK_POLL_SECONDS, min(stale_seconds / 4.0, 30.0))
275
+
276
+ def heartbeat() -> None:
277
+ while not stop_event.wait(interval):
278
+ try:
279
+ os.utime(_venv_lock_owner_path(lock_dir), None)
280
+ os.utime(lock_dir, None)
281
+ except FileNotFoundError:
282
+ return
283
+
284
+ thread = threading.Thread(
285
+ target=heartbeat,
286
+ name="code-mower-venv-lock-heartbeat",
287
+ daemon=True,
288
+ )
289
+ thread.start()
290
+ return VenvLock(
291
+ lock_dir=lock_dir,
292
+ owner_id=owner_id,
293
+ stop_event=stop_event,
294
+ heartbeat_thread=thread,
295
+ )
296
+
297
+
298
+ def _read_venv_lock_owner_id(owner_path: Path) -> str | None:
299
+ try:
300
+ payload = json.loads(owner_path.read_text(encoding="utf-8"))
301
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
302
+ return None
303
+ owner_id = payload.get("owner_id")
304
+ return str(owner_id) if owner_id else None
305
+
306
+
307
+ def _lock_mtime(lock_dir: Path, owner_path: Path) -> float:
308
+ mtimes = [lock_dir.stat().st_mtime]
309
+ try:
310
+ mtimes.append(owner_path.stat().st_mtime)
311
+ except FileNotFoundError:
312
+ pass
313
+ return max(mtimes)
314
+
315
+
316
+ def _remove_stale_venv_lock(lock_dir: Path, *, stale_seconds: float) -> bool:
317
+ if stale_seconds <= 0:
318
+ return False
319
+ owner_path = _venv_lock_owner_path(lock_dir)
320
+ try:
321
+ inspected_owner_id = _read_venv_lock_owner_id(owner_path)
322
+ lock_mtime = _lock_mtime(lock_dir, owner_path)
323
+ except FileNotFoundError:
324
+ return False
325
+ if time.time() - lock_mtime < stale_seconds:
326
+ return False
327
+ try:
328
+ current_owner_id = _read_venv_lock_owner_id(owner_path)
329
+ if current_owner_id != inspected_owner_id:
330
+ return False
331
+ if time.time() - _lock_mtime(lock_dir, owner_path) < stale_seconds:
332
+ return False
333
+ if owner_path.exists():
334
+ owner_path.unlink()
335
+ lock_dir.rmdir()
336
+ except (FileNotFoundError, OSError):
337
+ return False
338
+ return True
339
+
340
+
341
+ def _release_venv_lock(lock: VenvLock) -> None:
342
+ lock.stop_event.set()
343
+ lock.heartbeat_thread.join(timeout=1)
344
+ owner_path = _venv_lock_owner_path(lock.lock_dir)
345
+ try:
346
+ payload = json.loads(owner_path.read_text(encoding="utf-8"))
347
+ except FileNotFoundError:
348
+ return
349
+ except (OSError, json.JSONDecodeError):
350
+ return
351
+ if payload.get("owner_id") != lock.owner_id:
352
+ return
353
+ try:
354
+ owner_path.unlink()
355
+ lock.lock_dir.rmdir()
356
+ except FileNotFoundError:
357
+ return
358
+ except OSError:
359
+ return
360
+
361
+
362
+ def _safe_to_recreate(repo_root: Path, venv_dir: Path) -> bool:
363
+ resolved_repo = repo_root.resolve()
364
+ resolved_venv = venv_dir.resolve()
365
+ if resolved_venv == resolved_repo:
366
+ return False
367
+ if resolved_venv == Path(resolved_venv.anchor):
368
+ return False
369
+ try:
370
+ resolved_venv.relative_to(resolved_repo)
371
+ except ValueError:
372
+ return False
373
+ if "venv" not in resolved_venv.name:
374
+ return False
375
+ return (resolved_venv / "pyvenv.cfg").is_file()
376
+
377
+
378
+ def _ensure_venv(
379
+ *,
380
+ repo_root: Path,
381
+ venv_dir: Path,
382
+ recreate: bool,
383
+ runner: RunFn,
384
+ which: WhichFn,
385
+ ) -> tuple[Path, PythonInfo, bool]:
386
+ """Return the venv Python, selected base Python, and creation flag."""
387
+ venv_python = venv_python_path(venv_dir)
388
+ if venv_python.exists() and not recreate:
389
+ info = probe_python(str(venv_python), runner=runner)
390
+ if not info.supported:
391
+ raise RuntimeError(
392
+ f"{venv_python} is Python {info.version_text}; remove "
393
+ f"{venv_dir} or rerun with --recreate"
394
+ )
395
+ try:
396
+ base_python = find_supported_python(runner=runner, which=which)
397
+ except RuntimeError:
398
+ base_python = info
399
+ return venv_python, base_python, False
400
+
401
+ if recreate and venv_dir.exists():
402
+ if not _safe_to_recreate(repo_root, venv_dir):
403
+ raise RuntimeError(f"refusing to recreate unsafe venv path: {venv_dir}")
404
+ shutil.rmtree(venv_dir)
405
+
406
+ base_python = find_supported_python(runner=runner, which=which)
407
+ result = _run(
408
+ [base_python.executable, "-m", "venv", str(venv_dir)],
409
+ runner=runner,
410
+ capture_output=True,
411
+ )
412
+ if result.returncode != 0:
413
+ detail = (result.stderr or result.stdout or "").strip()
414
+ raise RuntimeError(f"failed to create {venv_dir}: {detail}")
415
+ venv_info = probe_python(str(venv_python), runner=runner)
416
+ if not venv_info.supported:
417
+ raise RuntimeError(
418
+ f"{venv_python} is Python {venv_info.version_text}; expected 3.11+"
419
+ )
420
+ return venv_python, base_python, True
421
+
422
+
423
+ def bootstrap(
424
+ *,
425
+ repo_root: Path | None = None,
426
+ venv_dir: Path | None = None,
427
+ requirements: Path | None = None,
428
+ recreate: bool = False,
429
+ install: bool = True,
430
+ runner: RunFn = subprocess.run,
431
+ which: WhichFn = shutil.which,
432
+ lock_timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
433
+ lock_poll_seconds: float = DEFAULT_LOCK_POLL_SECONDS,
434
+ lock_stale_seconds: float = DEFAULT_LOCK_STALE_SECONDS,
435
+ ) -> BootstrapResult:
436
+ root = (repo_root or _repo_root()).resolve()
437
+ venv = venv_dir or (root / DEFAULT_VENV)
438
+ if not venv.is_absolute():
439
+ venv = root / venv
440
+ req = requirements or (root / DEFAULT_REQUIREMENTS)
441
+ if not req.is_absolute():
442
+ req = root / req
443
+
444
+ lock_dir = venv_lock_dir(venv)
445
+ lock = _acquire_venv_lock(
446
+ lock_dir,
447
+ timeout_seconds=lock_timeout_seconds,
448
+ poll_seconds=lock_poll_seconds,
449
+ stale_seconds=lock_stale_seconds,
450
+ )
451
+ try:
452
+ venv_python, base_python, created = _ensure_venv(
453
+ repo_root=root,
454
+ venv_dir=venv,
455
+ recreate=recreate,
456
+ runner=runner,
457
+ which=which,
458
+ )
459
+ venv_info = probe_python(str(venv_python), runner=runner)
460
+ installed = False
461
+ if install:
462
+ if not req.exists():
463
+ raise RuntimeError(f"requirements file not found: {req}")
464
+ result = _run(
465
+ [str(venv_python), "-m", "pip", "install", "-r", str(req)],
466
+ runner=runner,
467
+ capture_output=True,
468
+ )
469
+ if result.returncode != 0:
470
+ detail = (result.stderr or result.stdout or "").strip()
471
+ raise RuntimeError(
472
+ f"failed to install Code Mower requirements: {detail}"
473
+ )
474
+ installed = True
475
+ finally:
476
+ _release_venv_lock(lock)
477
+ return BootstrapResult(
478
+ repo_root=root,
479
+ venv_dir=venv,
480
+ venv_python=venv_python,
481
+ base_python=base_python,
482
+ venv_python_info=venv_info,
483
+ requirements=req,
484
+ created=created,
485
+ installed=installed,
486
+ )
487
+
488
+
489
+ def main(argv: Sequence[str] | None = None) -> int:
490
+ parser = argparse.ArgumentParser()
491
+ parser.add_argument("--repo-root", default=str(_repo_root()))
492
+ parser.add_argument("--venv", default=DEFAULT_VENV)
493
+ parser.add_argument("--requirements", default=DEFAULT_REQUIREMENTS)
494
+ parser.add_argument("--recreate", action="store_true")
495
+ parser.add_argument("--no-install", action="store_true")
496
+ parser.add_argument("--json", action="store_true")
497
+ parser.add_argument("--print-python", action="store_true")
498
+ args = parser.parse_args(argv)
499
+
500
+ try:
501
+ result = bootstrap(
502
+ repo_root=Path(args.repo_root),
503
+ venv_dir=Path(args.venv),
504
+ requirements=Path(args.requirements),
505
+ recreate=args.recreate,
506
+ install=not args.no_install,
507
+ )
508
+ except RuntimeError as exc:
509
+ print(f"error: {exc}", file=sys.stderr)
510
+ return 1
511
+
512
+ if args.print_python:
513
+ print(result.venv_python)
514
+ elif args.json:
515
+ print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
516
+ else:
517
+ print(f"Code Mower Python: {result.venv_python}")
518
+ print(f"Version: {result.venv_python_info.version_text}")
519
+ print(f"Requirements: {result.requirements}")
520
+ return 0
521
+
522
+
523
+ if __name__ == "__main__":
524
+ raise SystemExit(main())