gabriel / muse public
which_muse.py python
156 lines 5.7 KB
Raw
sha256:133f9bcf57a62ec7ebf0cd71138b54a200b15989aea2fb2a6912497e2926aa8a feat(dev-safety): Phase 1 of #185 — audit tool for ambiguou… Sonnet 5 patch 2 days ago
1 #!/usr/bin/env python3
2 """Classify a ``muse`` executable as editable (live source checkout) or installed.
3
4 Part of #185 Phase 1 (musehub staging): audit every ``muse``-named binary
5 reachable on this machine so it's never ambiguous which build is running.
6 Editable installs (``pip install -e``) load the package directly from a repo
7 checkout — e.g. ``~/ecosystem/muse/muse/__init__.py`` — rather than from a
8 venv's copied ``site-packages`` tree. That distinction is the whole point:
9 an editable build may be mid-edit and should never be trusted against the
10 canonical object store it was built from (see #185 Phase 3's guard rail).
11
12 Usage::
13
14 python3 which_muse.py --json # classify `muse` resolved from PATH
15 python3 which_muse.py --target /path/to/muse --json
16 """
17 from __future__ import annotations
18
19 import argparse
20 import json
21 import re
22 import shutil
23 import subprocess
24 import sys
25 import tempfile
26 from pathlib import Path
27
28 _INTROSPECT_SNIPPET = (
29 "import json, muse\n"
30 "print(json.dumps({'file': muse.__file__, "
31 "'version': getattr(muse, '__version__', None)}))\n"
32 )
33
34
35 _PYTHON_NAME_RE = re.compile(r"python3?(\.\d+)?$")
36
37
38 def _resolve_shebang_argv(script_path: Path) -> list[str]:
39 """Return the argv that would actually launch ``script_path``'s interpreter.
40
41 Handles both a literal interpreter path (``#!/path/to/python3``) and the
42 indirect ``#!/usr/bin/env [-S] <name> [args...]`` form used by pip-generated
43 console scripts, resolving ``<name>`` against PATH like ``env`` itself would.
44 """
45 with open(script_path, encoding="utf-8", errors="replace") as f:
46 first_line = f.readline().rstrip("\n")
47 if not first_line.startswith("#!"):
48 raise RuntimeError(f"{script_path} has no shebang line — cannot determine its interpreter.")
49 tokens = first_line[2:].strip().split()
50 if not tokens:
51 raise RuntimeError(f"{script_path} has an empty shebang line.")
52
53 if Path(tokens[0]).name == "env":
54 rest = tokens[1:]
55 if rest and rest[0] == "-S":
56 rest = rest[1:]
57 if not rest:
58 raise RuntimeError(f"{script_path}'s `env` shebang names no command: {first_line!r}")
59 resolved = shutil.which(rest[0])
60 argv = [resolved or rest[0], *rest[1:]]
61 else:
62 argv = tokens
63
64 if not _PYTHON_NAME_RE.search(Path(argv[0]).name):
65 raise RuntimeError(
66 f"{script_path}'s interpreter ({argv[0]!r}) is not a Python interpreter — "
67 "cannot introspect its muse install. (Dispatcher shims like pyenv's "
68 "`#!/usr/bin/env bash` shims are not classifiable this way.)"
69 )
70 return argv
71
72
73 def classify_muse_binary(path: str, *, extra_pythonpath: str | None = None) -> dict:
74 """Classify the ``muse`` executable at ``path``.
75
76 Returns a dict with ``path``, ``resolved_path`` (symlinks followed),
77 ``editable`` (bool), ``version``, and ``source_root``.
78
79 ``extra_pythonpath`` is a test-only seam: real shims resolve their own
80 package via the interpreter's baked-in venv ``site-packages`` and never
81 need it. Fixtures use it to point a plain interpreter at a fake package
82 without needing a real ``pip install``.
83 """
84 resolved = Path(path).resolve()
85 if not resolved.exists():
86 raise FileNotFoundError(f"No such file: {path}")
87
88 interpreter_argv = _resolve_shebang_argv(resolved)
89
90 env = None
91 if extra_pythonpath is not None:
92 import os
93 env = os.environ.copy()
94 existing = env.get("PYTHONPATH", "")
95 env["PYTHONPATH"] = extra_pythonpath + (f":{existing}" if existing else "")
96
97 # cwd must NOT be inherited from the caller: Python's '' (cwd) sys.path
98 # entry is searched before PYTHONPATH, so running this from inside a repo
99 # that happens to have its own muse/ package at its root (e.g.
100 # ~/ecosystem/muse itself) would silently shadow the interpreter's real
101 # resolution — the exact ambiguity this tool exists to catch.
102 proc = subprocess.run(
103 [*interpreter_argv, "-c", _INTROSPECT_SNIPPET],
104 capture_output=True, text=True, env=env, cwd=tempfile.gettempdir(),
105 )
106 if proc.returncode != 0:
107 raise RuntimeError(
108 f"Could not import muse via interpreter {interpreter_argv!r}: {proc.stderr.strip()}"
109 )
110
111 info = json.loads(proc.stdout)
112 muse_file = Path(info["file"]).resolve()
113 # muse.__file__ is muse/__init__.py — its parent's parent is the source
114 # root (either a repo checkout root, or a venv's site-packages dir).
115 source_root = str(muse_file.parent.parent)
116 editable = "site-packages" not in source_root
117
118 return {
119 "path": str(path),
120 "resolved_path": str(resolved),
121 "editable": editable,
122 "version": info.get("version"),
123 "source_root": source_root,
124 }
125
126
127 def main(argv: list[str] | None = None) -> int:
128 parser = argparse.ArgumentParser(description=__doc__)
129 parser.add_argument(
130 "--target", default=None,
131 help="Path to the muse executable to classify. Defaults to `muse` resolved from PATH.",
132 )
133 parser.add_argument(
134 "--pythonpath", default=None,
135 help=argparse.SUPPRESS, # test-only seam, not for real use
136 )
137 parser.add_argument("--json", action="store_true", help="Emit JSON (the only supported output form).")
138 args = parser.parse_args(argv)
139
140 target = args.target or shutil.which("muse")
141 if target is None:
142 print("❌ No `muse` executable found on PATH.", file=sys.stderr)
143 return 1
144
145 try:
146 result = classify_muse_binary(target, extra_pythonpath=args.pythonpath)
147 except (FileNotFoundError, RuntimeError) as e:
148 print(f"❌ {e}", file=sys.stderr)
149 return 1
150
151 print(json.dumps(result))
152 return 0
153
154
155 if __name__ == "__main__":
156 sys.exit(main())
File History 1 commit
sha256:133f9bcf57a62ec7ebf0cd71138b54a200b15989aea2fb2a6912497e2926aa8a feat(dev-safety): Phase 1 of #185 — audit tool for ambiguou… Sonnet 5 patch 2 days ago