oct
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: click.Context,
242 target: str,
243 editable: bool,
244 reinstall: bool,
245 use_active_venv: bool,
246 allow_worktree: bool,
247 allow_outside_project: bool,
248 json_mode: bool,
249) -> None:
250 """Install an Option C project as an editable package, with safety checks.
251
252 Wraps ``pip install -e <target>`` after verifying:
253
254 1. The target is an Option C project (pyproject.toml + .octrc.json
255 or .option_c/).
256 2. The active venv matches the target's expected venv (refuse with
257 a precise diagnostic otherwise).
258 3. No existing shadow install from a different worktree is about
259 to be silently overwritten.
260 4. The target is not inside a Claude Code worktree (refusable
261 with --allow-worktree).
262 """
263 func = "oct_install_cmd"
264 _dbg(
265 "INSTALL", func,
266 f"target={target} editable={editable} reinstall={reinstall} "
267 f"use_active_venv={use_active_venv} allow_worktree={allow_worktree}",
268 2,
269 )
270
271 if not editable:
272 click.echo(
273 "Error: --editable / -e is required. Non-editable installs "
274 "are out of scope for `oct install`.",
275 err=True,
276 )
277 ctx.exit(1)
278 return
279
280 target_path = Path(target).resolve()
281 refusals: list[str] = []
282 warnings: list[str] = []
283
284 # -- Preflight 1: target is an Option C project --------------------
285 if not _is_option_c_project(target_path):
286 refusals.append(
287 f"target {target_path} is not an Option C project "
288 f"(missing pyproject.toml + (.octrc.json | .option_c/))"
289 )
290
291 # -- Preflight 2: not inside a Claude Code worktree (unless override) --
292 if _is_inside_worktree(target_path) and not allow_worktree:
293 refusals.append(
294 f"target {target_path} is inside a Claude Code worktree "
295 f"(.claude/worktrees/...). Pass --allow-worktree to override."
296 )
297
298 # -- Preflight 3: CWD is also an Option C project (escape hatch) ---
299 cwd = Path.cwd().resolve()
300 if not allow_outside_project and not _is_option_c_project(cwd):
301 # Allow the target to be the CWD even if cwd lacks the markers
302 # outright - we already validate the target above.
303 if cwd != target_path:
304 warnings.append(
305 f"CWD {cwd} is not an Option C project root. "
306 f"Continuing because target {target_path} is."
307 )
308
309 # -- Preflight 4: active venv matches expected venv ----------------
310 expected_venv = _expected_venv_under(target_path)
311 active_venv = _active_venv_root()
312
313 if not use_active_venv:
314 if expected_venv is None:
315 warnings.append(
316 f"target has no local venv at {target_path}/.venv "
317 f"or {target_path}/venv; skipping venv-match check"
318 )
319 elif active_venv is None:
320 refusals.append(
321 f"no active venv detected; project expects {expected_venv}. "
322 f"Activate the venv (.venv\\Scripts\\activate or "
323 f"source .venv/bin/activate), or pass --use-active-venv."
324 )
325 else:
326 try:
327 same = active_venv.samefile(expected_venv)
328 except OSError:
329 same = active_venv == expected_venv
330 if not same:
331 refusals.append(
332 f"active venv is at {active_venv}; project expects "
333 f"{expected_venv}. Activate the right venv, or pass "
334 f"--use-active-venv to override."
335 )
336
337 # -- Preflight 5: existing-shadow detection ------------------------
338 pkg_name = _read_project_name(target_path)
339 shadow_loc: Path | None = None
340 if not refusals and pkg_name:
341 shadow_loc = _pip_show_location(
342 pkg_name,
343 active_venv if use_active_venv or expected_venv is None
344 else expected_venv,
345 )
346 if shadow_loc is not None:
347 # Compare against the target's package directory. For a
348 # standard layout that's <target>/<pkg_name>/.
349 expected_loc = target_path
350 if not _is_under(shadow_loc, expected_loc):
351 if not reinstall:
352 refusals.append(
353 f"package '{pkg_name}' is already installed "
354 f"with Location={shadow_loc}, which is NOT under "
355 f"the install target {target_path}. This would "
356 f"silently shadow another worktree's install. "
357 f"Pass --reinstall to overwrite, or activate the "
358 f"right venv."
359 )
360 else:
361 warnings.append(
362 f"overwriting existing install at {shadow_loc} "
363 f"(--reinstall acknowledged)"
364 )
365
366 # -- Render preflight result ---------------------------------------
367 if refusals:
368 if json_mode:
369 click.echo(json.dumps({
370 "operation": "install",
371 "target": str(target_path),
372 "exit_code": 1,
373 "errors": refusals,
374 "warnings": warnings,
375 }, indent=2))
376 else:
377 click.echo("oct install preflight FAILED:", err=True)
378 for r in refusals:
379 click.echo(f" - {r}", err=True)
380 for w in warnings:
381 click.echo(f" ! {w}", err=True)
382 ctx.exit(1)
383 return
384
385 if warnings and not json_mode:
386 for w in warnings:
387 click.echo(f"Warning: {w}", err=True)
388
389 # -- Execute pip install -e ----------------------------------------
390 pip_python = _venv_python(active_venv) if active_venv else Path(sys.executable)
391 pip_argv = [str(pip_python), "-m", "pip", "install", "-e", str(target_path)]
392 _dbg("INSTALL", func, f"running: {pip_argv}", 2)
393
394 try:
395 proc = subprocess.run(
396 pip_argv, shell=False, timeout=_PIP_TIMEOUT,
397 text=True, capture_output=json_mode,
398 )
399 except subprocess.TimeoutExpired:
400 click.echo(
401 f"pip install timed out after {_PIP_TIMEOUT}s.",
402 err=True,
403 )
404 ctx.exit(2)
405 return
406 except FileNotFoundError as exc:
407 click.echo(f"pip executable not found: {exc}", err=True)
408 ctx.exit(2)
409 return
410
411 if proc.returncode != 0:
412 if json_mode:
413 click.echo(json.dumps({
414 "operation": "install",
415 "target": str(target_path),
416 "exit_code": 2,
417 "errors": [f"pip install failed (exit {proc.returncode})"],
418 "stdout": proc.stdout, "stderr": proc.stderr,
419 }, indent=2))
420 else:
421 click.echo(
422 f"pip install failed (exit {proc.returncode}).",
423 err=True,
424 )
425 ctx.exit(2)
426 return
427
428 # -- Post-install verification -------------------------------------
429 new_loc = _pip_show_location(pkg_name, active_venv) if pkg_name else None
430 oct_path = _which_oct(active_venv)
431
432 if json_mode:
433 click.echo(json.dumps({
434 "operation": "install",
435 "target": str(target_path),
436 "package": pkg_name,
437 "venv": str(active_venv) if active_venv else None,
438 "installed_location": str(new_loc) if new_loc else None,
439 "oct_binary": str(oct_path) if oct_path else None,
440 "exit_code": 0,
441 "warnings": warnings,
442 }, indent=2))
443 else:
444 click.echo(f"\noct install: {pkg_name or '<pkg>'} installed.")
445 if new_loc:
446 click.echo(f" Location: {new_loc}")
447 if oct_path:
448 click.echo(f" oct binary: {oct_path}")
449 if active_venv:
450 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)
None oct_install_cmd(click.Context ctx, str target, bool editable, bool reinstall, bool use_active_venv, bool allow_worktree, bool allow_outside_project, bool 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)