#!/usr/bin/env python3 """Classify a ``muse`` executable as editable (live source checkout) or installed. Part of #185 Phase 1 (musehub staging): audit every ``muse``-named binary reachable on this machine so it's never ambiguous which build is running. Editable installs (``pip install -e``) load the package directly from a repo checkout — e.g. ``~/ecosystem/muse/muse/__init__.py`` — rather than from a venv's copied ``site-packages`` tree. That distinction is the whole point: an editable build may be mid-edit and should never be trusted against the canonical object store it was built from (see #185 Phase 3's guard rail). Usage:: python3 which_muse.py --json # classify `muse` resolved from PATH python3 which_muse.py --target /path/to/muse --json """ from __future__ import annotations import argparse import json import re import shutil import subprocess import sys import tempfile from pathlib import Path _INTROSPECT_SNIPPET = ( "import json, muse\n" "print(json.dumps({'file': muse.__file__, " "'version': getattr(muse, '__version__', None)}))\n" ) _PYTHON_NAME_RE = re.compile(r"python3?(\.\d+)?$") def _resolve_shebang_argv(script_path: Path) -> list[str]: """Return the argv that would actually launch ``script_path``'s interpreter. Handles both a literal interpreter path (``#!/path/to/python3``) and the indirect ``#!/usr/bin/env [-S] [args...]`` form used by pip-generated console scripts, resolving ```` against PATH like ``env`` itself would. """ with open(script_path, encoding="utf-8", errors="replace") as f: first_line = f.readline().rstrip("\n") if not first_line.startswith("#!"): raise RuntimeError(f"{script_path} has no shebang line — cannot determine its interpreter.") tokens = first_line[2:].strip().split() if not tokens: raise RuntimeError(f"{script_path} has an empty shebang line.") if Path(tokens[0]).name == "env": rest = tokens[1:] if rest and rest[0] == "-S": rest = rest[1:] if not rest: raise RuntimeError(f"{script_path}'s `env` shebang names no command: {first_line!r}") resolved = shutil.which(rest[0]) argv = [resolved or rest[0], *rest[1:]] else: argv = tokens if not _PYTHON_NAME_RE.search(Path(argv[0]).name): raise RuntimeError( f"{script_path}'s interpreter ({argv[0]!r}) is not a Python interpreter — " "cannot introspect its muse install. (Dispatcher shims like pyenv's " "`#!/usr/bin/env bash` shims are not classifiable this way.)" ) return argv def classify_muse_binary(path: str, *, extra_pythonpath: str | None = None) -> dict: """Classify the ``muse`` executable at ``path``. Returns a dict with ``path``, ``resolved_path`` (symlinks followed), ``editable`` (bool), ``version``, and ``source_root``. ``extra_pythonpath`` is a test-only seam: real shims resolve their own package via the interpreter's baked-in venv ``site-packages`` and never need it. Fixtures use it to point a plain interpreter at a fake package without needing a real ``pip install``. """ resolved = Path(path).resolve() if not resolved.exists(): raise FileNotFoundError(f"No such file: {path}") interpreter_argv = _resolve_shebang_argv(resolved) env = None if extra_pythonpath is not None: import os env = os.environ.copy() existing = env.get("PYTHONPATH", "") env["PYTHONPATH"] = extra_pythonpath + (f":{existing}" if existing else "") # cwd must NOT be inherited from the caller: Python's '' (cwd) sys.path # entry is searched before PYTHONPATH, so running this from inside a repo # that happens to have its own muse/ package at its root (e.g. # ~/ecosystem/muse itself) would silently shadow the interpreter's real # resolution — the exact ambiguity this tool exists to catch. proc = subprocess.run( [*interpreter_argv, "-c", _INTROSPECT_SNIPPET], capture_output=True, text=True, env=env, cwd=tempfile.gettempdir(), ) if proc.returncode != 0: raise RuntimeError( f"Could not import muse via interpreter {interpreter_argv!r}: {proc.stderr.strip()}" ) info = json.loads(proc.stdout) muse_file = Path(info["file"]).resolve() # muse.__file__ is muse/__init__.py — its parent's parent is the source # root (either a repo checkout root, or a venv's site-packages dir). source_root = str(muse_file.parent.parent) editable = "site-packages" not in source_root return { "path": str(path), "resolved_path": str(resolved), "editable": editable, "version": info.get("version"), "source_root": source_root, } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--target", default=None, help="Path to the muse executable to classify. Defaults to `muse` resolved from PATH.", ) parser.add_argument( "--pythonpath", default=None, help=argparse.SUPPRESS, # test-only seam, not for real use ) parser.add_argument("--json", action="store_true", help="Emit JSON (the only supported output form).") args = parser.parse_args(argv) target = args.target or shutil.which("muse") if target is None: print("❌ No `muse` executable found on PATH.", file=sys.stderr) return 1 try: result = classify_muse_binary(target, extra_pythonpath=args.pythonpath) except (FileNotFoundError, RuntimeError) as e: print(f"❌ {e}", file=sys.stderr) return 1 print(json.dumps(result)) return 0 if __name__ == "__main__": sys.exit(main())