Option C Tools
Loading...
Searching...
No Matches
oct_install.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/install/oct_install.py
4
5"""
6Purpose
7-------
8Wrap ``pip install -e <target>`` with safety preflight checks that
9catch the most common operator-error patterns: running the install
10from the wrong terminal window with the wrong venv active, in the
11wrong CWD, or against a worktree that would shadow a sibling worktree.
12
13Responsibilities
14----------------
15- Define the :func:`oct_install_cmd` Click command, mounted on the
16 top-level ``cli`` group.
17- Resolve and validate the install target (an Option C project root).
18- Verify the active venv matches the target's expected venv.
19- Detect existing-shadow installs from another worktree.
20- Refuse to install from inside a Claude Code worktree without an
21 explicit override.
22- Delegate to ``pip install -e <target>`` via :mod:`subprocess` after
23 preflights succeed; print post-install diagnostics so the user
24 sees exactly which ``oct`` binary will be invoked next.
25
26Diagnostics
27-----------
28Domain: INSTALL
29Levels:
30 L1 — errors (pip failure, preflight refusal)
31 L2 — lifecycle (preflight start/end, install start/end)
32 L3 — details (venv path, target path, shadow location)
33 L4 — deep trace (each preflight check)
34
35Contracts
36---------
37- All subprocess calls use ``shell=False`` and explicit argv lists.
38- No filesystem writes are performed by this command itself; only
39 ``pip`` (the delegated subprocess) writes to the venv's
40 ``site-packages``.
41- Audit trail via the existing :func:`oct.git.audit.audited` decorator
42 is intentionally NOT applied here, because the audit module emits
43 to ``logs/git-audit-*.jsonl`` which lives under the project root —
44 but ``oct install`` may run before any project root exists. A
45 lightweight ``_dbg`` trace is kept instead.
46
47Dependencies
48------------
49- oct.core.diagnostics (_dbg structured logger)
50- click (CLI framework)
51"""
52
53from __future__ import annotations
54
55import json
56import subprocess
57import sys
58import tomllib
59from pathlib import Path
60
61import click
62
63from oct.core.diagnostics import _dbg
64
65
66# ---------------------------------------------------------------------
67# Constants
68# ---------------------------------------------------------------------
69
70#: Subdirectories under a project root that are accepted as the local
71#: venv. Both names are common; the user's `oct health` Venv-First Rule
72#: documents `.venv`, but pre-existing setups may use `venv`.
73_LOCAL_VENV_NAMES: tuple[str, ...] = (".venv", "venv")
74
75#: Default timeout for the pip subprocess, in seconds. Generous so a
76#: cold-cache resolution doesn't trip.
77_PIP_TIMEOUT: int = 600
78
79
80# ---------------------------------------------------------------------
81# Helpers
82# ---------------------------------------------------------------------
83
84
85def _is_option_c_project(target: Path) -> bool:
86 """Return True if *target* looks like an Option C project root.
87
88 Accept both legacy (``.octrc.json`` at root) and migrated
89 (``.option_c/`` directory) layouts. ``pyproject.toml`` must also
90 be present so ``pip install -e`` has something to install.
91 """
92 if not (target / "pyproject.toml").is_file():
93 return False
94 return (target / ".octrc.json").is_file() \
95 or (target / ".option_c").is_dir()
96
97
98def _read_project_name(target: Path) -> str | None:
99 """Return the ``[project] name`` from *target*'s pyproject.toml."""
100 func = "_read_project_name"
101 pyproject = target / "pyproject.toml"
102 if not pyproject.is_file():
103 return None
104 try:
105 with pyproject.open("rb") as fh:
106 data = tomllib.load(fh)
107 except (OSError, tomllib.TOMLDecodeError) as exc:
108 _dbg("INSTALL", func, f"pyproject parse failed: {exc}", 1, override=True)
109 return None
110 project = data.get("project")
111 if not isinstance(project, dict):
112 return None
113 name = project.get("name")
114 return name if isinstance(name, str) else None
115
116
117def _active_venv_root() -> Path | None:
118 """Return the root of the currently active venv, or ``None``.
119
120 ``sys.prefix`` is the venv root for an active virtual environment,
121 ``sys.base_prefix`` is the system Python. When they differ, a venv
122 is active. If they match, no venv is active.
123 """
124 if sys.prefix != sys.base_prefix:
125 return Path(sys.prefix).resolve()
126 return None
127
128
129def _expected_venv_under(target: Path) -> Path | None:
130 """Return the first existing local venv directory under *target*."""
131 target_resolved = target.resolve()
132 for name in _LOCAL_VENV_NAMES:
133 candidate = target_resolved / name
134 if candidate.is_dir():
135 return candidate
136 return None
137
138
139def _is_under(child: Path, parent: Path) -> bool:
140 """Return True if *child* is at or below *parent*."""
141 try:
142 child.resolve().relative_to(parent.resolve())
143 return True
144 except ValueError:
145 return False
146
147
148def _pip_show_location(pkg: str, venv: Path | None) -> Path | None:
149 """Return the ``Location:`` from ``pip show <pkg>`` or ``None``.
150
151 Runs ``<venv>/Scripts/python -m pip show <pkg>`` (Windows) or
152 ``<venv>/bin/python -m pip show <pkg>`` (Unix) when *venv* is
153 given; falls back to ``sys.executable`` otherwise.
154 """
155 func = "_pip_show_location"
156 python = _venv_python(venv) if venv is not None else Path(sys.executable)
157 try:
158 proc = subprocess.run(
159 [str(python), "-m", "pip", "show", pkg],
160 shell=False, capture_output=True, text=True, timeout=30,
161 )
162 except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
163 _dbg("INSTALL", func, f"pip show failed: {exc}", 1, override=True)
164 return None
165 if proc.returncode != 0:
166 return None
167 for line in proc.stdout.splitlines():
168 if line.startswith("Location:"):
169 loc = line.split(":", 1)[1].strip()
170 if loc:
171 return Path(loc).resolve()
172 return None
173
174
175def _venv_python(venv: Path) -> Path:
176 """Return the python executable path inside *venv*."""
177 if (venv / "Scripts" / "python.exe").is_file():
178 return (venv / "Scripts" / "python.exe").resolve()
179 if (venv / "bin" / "python").is_file():
180 return (venv / "bin" / "python").resolve()
181 return Path(sys.executable).resolve()
182
183
184def _which_oct(venv: Path | None) -> Path | None:
185 """Return the path to the ``oct`` script, preferring *venv*'s bin."""
186 if venv is not None:
187 for sub in ("Scripts/oct.exe", "Scripts/oct", "bin/oct"):
188 candidate = venv / sub
189 if candidate.is_file():
190 return candidate.resolve()
191 # Fallback: scan PATH.
192 import shutil as _sh
193 found = _sh.which("oct")
194 return Path(found).resolve() if found else None
195
196
197def _is_inside_worktree(target: Path) -> bool:
198 """Return True if *target* lives under any ``.claude/worktrees/`` ancestor."""
199 target_resolved = target.resolve()
200 for parent in [target_resolved, *target_resolved.parents]:
201 if parent.name == "worktrees" and parent.parent.name == ".claude":
202 return True
203 return False
204
205
206# ---------------------------------------------------------------------
207# Click command
208# ---------------------------------------------------------------------
209
210
211@click.command(name="install")
212@click.argument(
213 "target", type=click.Path(file_okay=False, dir_okay=True),
214 default=".",
215)
216@click.option(
217 "--editable", "-e", "editable", is_flag=True,
218 help="Install in editable mode (pip install -e). Required for now.",
219)
220@click.option(
221 "--reinstall", is_flag=True,
222 help="Allow re-install over an existing shadow install.",
223)
224@click.option(
225 "--use-active-venv", is_flag=True,
226 help="Bypass the venv-match check and use whichever venv is active.",
227)
228@click.option(
229 "--allow-worktree", is_flag=True,
230 help="Allow installing from inside a Claude Code worktree.",
231)
232@click.option(
233 "--allow-outside-project", is_flag=True,
234 help="Allow running outside a recognised Option C project.",
235)
236@click.option(
237 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
238)
239@click.pass_context
241 ctx, target, editable, reinstall, use_active_venv,
242 allow_worktree, allow_outside_project, json_mode,
243):
244 """Install an Option C project as an editable package, with safety checks.
245
246 Wraps ``pip install -e <target>`` after verifying:
247
248 1. The target is an Option C project (pyproject.toml + .octrc.json
249 or .option_c/).
250 2. The active venv matches the target's expected venv (refuse with
251 a precise diagnostic otherwise).
252 3. No existing shadow install from a different worktree is about
253 to be silently overwritten.
254 4. The target is not inside a Claude Code worktree (refusable
255 with --allow-worktree).
256 """
257 func = "oct_install_cmd"
258 _dbg(
259 "INSTALL", func,
260 f"target={target} editable={editable} reinstall={reinstall} "
261 f"use_active_venv={use_active_venv} allow_worktree={allow_worktree}",
262 2,
263 )
264
265 if not editable:
266 click.echo(
267 "Error: --editable / -e is required. Non-editable installs "
268 "are out of scope for `oct install`.",
269 err=True,
270 )
271 ctx.exit(1)
272 return
273
274 target_path = Path(target).resolve()
275 refusals: list[str] = []
276 warnings: list[str] = []
277
278 # -- Preflight 1: target is an Option C project --------------------
279 if not _is_option_c_project(target_path):
280 refusals.append(
281 f"target {target_path} is not an Option C project "
282 f"(missing pyproject.toml + (.octrc.json | .option_c/))"
283 )
284
285 # -- Preflight 2: not inside a Claude Code worktree (unless override) --
286 if _is_inside_worktree(target_path) and not allow_worktree:
287 refusals.append(
288 f"target {target_path} is inside a Claude Code worktree "
289 f"(.claude/worktrees/...). Pass --allow-worktree to override."
290 )
291
292 # -- Preflight 3: CWD is also an Option C project (escape hatch) ---
293 cwd = Path.cwd().resolve()
294 if not allow_outside_project and not _is_option_c_project(cwd):
295 # Allow the target to be the CWD even if cwd lacks the markers
296 # outright — we already validate the target above.
297 if cwd != target_path:
298 warnings.append(
299 f"CWD {cwd} is not an Option C project root. "
300 f"Continuing because target {target_path} is."
301 )
302
303 # -- Preflight 4: active venv matches expected venv ----------------
304 expected_venv = _expected_venv_under(target_path)
305 active_venv = _active_venv_root()
306
307 if not use_active_venv:
308 if expected_venv is None:
309 warnings.append(
310 f"target has no local venv at {target_path}/.venv "
311 f"or {target_path}/venv; skipping venv-match check"
312 )
313 elif active_venv is None:
314 refusals.append(
315 f"no active venv detected; project expects {expected_venv}. "
316 f"Activate the venv (.venv\\Scripts\\activate or "
317 f"source .venv/bin/activate), or pass --use-active-venv."
318 )
319 else:
320 try:
321 same = active_venv.samefile(expected_venv)
322 except OSError:
323 same = active_venv == expected_venv
324 if not same:
325 refusals.append(
326 f"active venv is at {active_venv}; project expects "
327 f"{expected_venv}. Activate the right venv, or pass "
328 f"--use-active-venv to override."
329 )
330
331 # -- Preflight 5: existing-shadow detection ------------------------
332 pkg_name = _read_project_name(target_path)
333 shadow_loc: Path | None = None
334 if not refusals and pkg_name:
335 shadow_loc = _pip_show_location(
336 pkg_name,
337 active_venv if use_active_venv or expected_venv is None
338 else expected_venv,
339 )
340 if shadow_loc is not None:
341 # Compare against the target's package directory. For a
342 # standard layout that's <target>/<pkg_name>/.
343 expected_loc = target_path
344 if not _is_under(shadow_loc, expected_loc):
345 if not reinstall:
346 refusals.append(
347 f"package '{pkg_name}' is already installed "
348 f"with Location={shadow_loc}, which is NOT under "
349 f"the install target {target_path}. This would "
350 f"silently shadow another worktree's install. "
351 f"Pass --reinstall to overwrite, or activate the "
352 f"right venv."
353 )
354 else:
355 warnings.append(
356 f"overwriting existing install at {shadow_loc} "
357 f"(--reinstall acknowledged)"
358 )
359
360 # -- Render preflight result ---------------------------------------
361 if refusals:
362 if json_mode:
363 click.echo(json.dumps({
364 "operation": "install",
365 "target": str(target_path),
366 "exit_code": 1,
367 "errors": refusals,
368 "warnings": warnings,
369 }, indent=2))
370 else:
371 click.echo("oct install preflight FAILED:", err=True)
372 for r in refusals:
373 click.echo(f" - {r}", err=True)
374 for w in warnings:
375 click.echo(f" ! {w}", err=True)
376 ctx.exit(1)
377 return
378
379 if warnings and not json_mode:
380 for w in warnings:
381 click.echo(f"Warning: {w}", err=True)
382
383 # -- Execute pip install -e ----------------------------------------
384 pip_python = _venv_python(active_venv) if active_venv else Path(sys.executable)
385 pip_argv = [str(pip_python), "-m", "pip", "install", "-e", str(target_path)]
386 _dbg("INSTALL", func, f"running: {pip_argv}", 2)
387
388 try:
389 proc = subprocess.run(
390 pip_argv, shell=False, timeout=_PIP_TIMEOUT,
391 text=True, capture_output=json_mode,
392 )
393 except subprocess.TimeoutExpired:
394 click.echo(
395 f"pip install timed out after {_PIP_TIMEOUT}s.",
396 err=True,
397 )
398 ctx.exit(2)
399 return
400 except FileNotFoundError as exc:
401 click.echo(f"pip executable not found: {exc}", err=True)
402 ctx.exit(2)
403 return
404
405 if proc.returncode != 0:
406 if json_mode:
407 click.echo(json.dumps({
408 "operation": "install",
409 "target": str(target_path),
410 "exit_code": 2,
411 "errors": [f"pip install failed (exit {proc.returncode})"],
412 "stdout": proc.stdout, "stderr": proc.stderr,
413 }, indent=2))
414 else:
415 click.echo(
416 f"pip install failed (exit {proc.returncode}).",
417 err=True,
418 )
419 ctx.exit(2)
420 return
421
422 # -- Post-install verification -------------------------------------
423 new_loc = _pip_show_location(pkg_name, active_venv) if pkg_name else None
424 oct_path = _which_oct(active_venv)
425
426 if json_mode:
427 click.echo(json.dumps({
428 "operation": "install",
429 "target": str(target_path),
430 "package": pkg_name,
431 "venv": str(active_venv) if active_venv else None,
432 "installed_location": str(new_loc) if new_loc else None,
433 "oct_binary": str(oct_path) if oct_path else None,
434 "exit_code": 0,
435 "warnings": warnings,
436 }, indent=2))
437 else:
438 click.echo(f"\noct install: {pkg_name or '<pkg>'} installed.")
439 if new_loc:
440 click.echo(f" Location: {new_loc}")
441 if oct_path:
442 click.echo(f" oct binary: {oct_path}")
443 if active_venv:
444 click.echo(f" venv: {active_venv}")
Path|None _pip_show_location(str pkg, Path|None venv)
Path|None _expected_venv_under(Path target)
Path|None _active_venv_root()
Path _venv_python(Path venv)
str|None _read_project_name(Path target)
oct_install_cmd(ctx, target, editable, reinstall, use_active_venv, allow_worktree, allow_outside_project, json_mode)
bool _is_inside_worktree(Path target)
bool _is_under(Path child, Path parent)
bool _is_option_c_project(Path target)
Path|None _which_oct(Path|None venv)