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.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
code_mower/bootstrap.py
ADDED
|
@@ -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())
|