fix(macos): re-land dylib-complete TCC interpreter anchor
The first landing (#95131/#95478, reverted in #95563) copied the uv-store interpreter into venv/bin/python so TCC grants would stick to a stable path. On real Macs that copy bricked every hermes command two ways: dynamically-linked builds died in dyld because @executable_path/../lib/libpython resolved into venv/lib/ (#95425), and alias symlinks to the copy made CPython getpath lose the venv prefix (#95541, ModuleNotFoundError: encodings). Re-land: - Keep the signed real-file copy of bin/python (identifier-pinned via _macos_sign_managed_python). - Materialize python3 / python3.N as real-file copies, never symlinks. Copies boot on every build we could reproduce and keep the TCC identity. - Hardlink store libpython* into venv/lib/ when present (copy across devices). Existing LC_RPATH already points there. - Pre-install boot gate: launch the staged copy, demand encodings plus the venv prefix, abort and leave the live venv untouched on failure. Doctor reports/installs the new anchor (the revert-era heal is removed). Update refreshes it after a successful code swap. Tests cover layout, idempotence, predecessor-symlink repair, libpython hardlink, boot-gate refusal, and a macos_only real-interpreter E2E. Closes #95596.
This commit is contained in:
@@ -1154,54 +1154,33 @@ def _macos_desktop_dr(app: Path) -> str | None:
|
||||
return (proc.stdout or "") + (proc.stderr or "")
|
||||
|
||||
|
||||
def check_macos_tcc_anchor_removed() -> None:
|
||||
"""Detect and repair a venv bricked by the reverted TCC anchor.
|
||||
def check_macos_tcc_anchor(should_fix: bool = False) -> None:
|
||||
"""Report (and optionally install) the dylib-complete TCC anchor (#95596).
|
||||
|
||||
The anchor (#95131/#95478, reverted) replaced ``venv/bin/python`` with a
|
||||
real-file copy of the uv-store interpreter. On real Macs that copy could
|
||||
not start: its ``LC_RPATH`` (``@executable_path/../lib``) resolved to
|
||||
``venv/lib/``, which holds no libpython — every hermes command died in
|
||||
dyld (#95425), and re-pointed aliases lost the stdlib (#95541). The
|
||||
revert stops NEW anchors; this check heals venvs the anchor already
|
||||
converted, by restoring ``bin/python`` to a symlink pointing at the
|
||||
recorded source interpreter (the marker file the anchor wrote).
|
||||
Silent on non-macOS and on venvs the anchor never touched.
|
||||
Silent on non-macOS and for interpreters that are not uv-managed. Never
|
||||
raises — a failed check must not crash doctor. Install is gated by the
|
||||
module's pre-install boot probe, so ``--fix`` cannot brick the CLI.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return
|
||||
# Resolved at call time via the module global so tests can retarget it.
|
||||
root = Path(globals()["__file__"]).resolve().parents[1]
|
||||
for name in ("venv", ".venv"):
|
||||
venv_bin = root / name / "bin"
|
||||
marker = venv_bin / ".tcc-anchor-source"
|
||||
if not marker.is_file():
|
||||
continue
|
||||
try:
|
||||
source = Path(marker.read_text(encoding="utf-8").strip())
|
||||
venv_py = venv_bin / "python"
|
||||
if source.is_file() and venv_py.is_file() and not venv_py.is_symlink():
|
||||
tmp = venv_bin / ".python-unanchor-tmp"
|
||||
tmp.unlink(missing_ok=True)
|
||||
os.symlink(source, tmp)
|
||||
os.replace(tmp, venv_py)
|
||||
# Restore versioned aliases to point at bin/python.
|
||||
for alias in venv_bin.glob("python3*"):
|
||||
if alias.is_symlink() or alias.is_file():
|
||||
alias_tmp = venv_bin / f".{alias.name}.unanchor-tmp"
|
||||
alias_tmp.unlink(missing_ok=True)
|
||||
os.symlink("python", alias_tmp)
|
||||
os.replace(alias_tmp, alias)
|
||||
marker.unlink(missing_ok=True)
|
||||
check_ok(
|
||||
"macOS TCC anchor removed",
|
||||
f"({name}/bin/python restored to a symlink; the anchor "
|
||||
"(#95425/#95541) is reverted)",
|
||||
)
|
||||
except Exception as e: # diagnostics must never crash
|
||||
check_warn(
|
||||
"macOS TCC anchor cleanup failed",
|
||||
f"({e}) — restore manually: ln -sf $(cat {marker}) {venv_bin / 'python'}",
|
||||
)
|
||||
try:
|
||||
from hermes_cli import macos_tcc_anchor as tcc
|
||||
|
||||
status, detail = tcc.tcc_anchor_state()
|
||||
if status == "skip":
|
||||
return
|
||||
if status == "active":
|
||||
check_ok("macOS TCC anchor active", f"({detail})")
|
||||
return
|
||||
if should_fix:
|
||||
anchored = tcc.ensure_tcc_anchor()
|
||||
if anchored is not None:
|
||||
check_ok("macOS TCC anchor installed", f"({anchored})")
|
||||
return
|
||||
check_warn(
|
||||
"macOS TCC anchor missing" if status == "missing" else "macOS TCC anchor stale",
|
||||
f"({detail})",
|
||||
)
|
||||
except Exception as e: # diagnostics must never crash
|
||||
check_warn("macOS TCC anchor check failed", f"({e})")
|
||||
|
||||
|
||||
def check_macos_full_disk_access() -> None:
|
||||
@@ -1423,10 +1402,9 @@ def run_doctor(args):
|
||||
else:
|
||||
check_warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
# macOS TCC anchor REVERTED (#95425/#95541: anchored copies couldn't load
|
||||
# libpython — every hermes command died in dyld). This heals venvs the
|
||||
# anchor already converted. Silent on non-macOS.
|
||||
check_macos_tcc_anchor_removed()
|
||||
# macOS TCC interpreter anchor (#95596): dylib-complete re-land of the
|
||||
# mechanism reverted in #95563. Silent on non-macOS.
|
||||
check_macos_tcc_anchor(should_fix=should_fix)
|
||||
|
||||
# macOS Full Disk Access (issue #52010 follow-up): one grant silences
|
||||
# every per-folder prompt permanently. Silent on non-macOS.
|
||||
|
||||
371
hermes_cli/macos_tcc_anchor.py
Normal file
371
hermes_cli/macos_tcc_anchor.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Stable macOS TCC anchor for the uv-managed Python interpreter (#95596).
|
||||
|
||||
Re-land of the interpreter anchor reverted in #95563. macOS keys TCC grants
|
||||
to the resolved absolute path of the client binary. Hermes' interpreter is
|
||||
managed by uv and lives at a versioned store path; every patch bump orphans
|
||||
every prior grant (#85345).
|
||||
|
||||
The first landing copied the interpreter into ``venv/bin/python`` but left
|
||||
two holes that bricked real Macs:
|
||||
|
||||
* Dynamically-linked builds look up ``libpython`` via
|
||||
``@executable_path/../lib``. That resolved into ``venv/lib/``, which had
|
||||
no dylib — every hermes command, including update/doctor, died in dyld
|
||||
(#95425).
|
||||
* Alias names (``python3``, ``python3.N``) were re-pointed at the copy as
|
||||
*symlinks*. Invoking the copied interpreter through a symlink makes
|
||||
CPython getpath lose the venv prefix on affected python-build-standalone
|
||||
builds — startup dies with ``ModuleNotFoundError: encodings`` and the
|
||||
stdlib resolves to the build-time ``/install`` prefix (#95541). Console
|
||||
scripts exec ``python3``, so the entire CLI surface died.
|
||||
|
||||
This re-land keeps the copy + identifier-pinned signature (TCC attribution
|
||||
stays on the stable venv path) and closes both holes:
|
||||
|
||||
1. Aliases are materialized as real-file copies of the anchor, never
|
||||
symlinks.
|
||||
2. If the store ships ``libpython*``, it is hardlinked into ``venv/lib/``
|
||||
(copy if the store is on another device). Existing ``LC_RPATH`` already
|
||||
points at ``@executable_path/../lib`` — no rewrite.
|
||||
3. A pre-install boot gate actually launches the staged copy and demands
|
||||
``import encodings`` plus ``sys.prefix == <venv>``. Failure rolls the
|
||||
staging file back and leaves the live venv untouched, so a bad anchor
|
||||
can never brick update/doctor again.
|
||||
|
||||
All functions are no-ops on non-macOS and for interpreters that are not
|
||||
uv-managed. Best-effort: never raises to callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import venv_python_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MARKER_NAME = ".tcc-anchor-source"
|
||||
|
||||
_STORE_COMMON_MARKERS = ("cpython-", "-macos-")
|
||||
_STORE_ROOT_MARKERS = ("/uv/python/", "/.hermes-runtime/python/")
|
||||
|
||||
|
||||
class _BootGateFailed(Exception):
|
||||
"""Staged copy refused to boot; the live venv must stay untouched."""
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def _sibling_names() -> tuple[str, ...]:
|
||||
"""Alias names uv creates inside the venv bin dir."""
|
||||
import sys as _sys
|
||||
|
||||
return ("python3", f"python3.{_sys.version_info.minor}")
|
||||
|
||||
|
||||
def _store_bin_names() -> tuple[str, ...]:
|
||||
"""Preferred interpreter file names inside a store ``bin`` dir."""
|
||||
import sys as _sys
|
||||
|
||||
return (f"python3.{_sys.version_info.minor}", "python3", "python")
|
||||
|
||||
|
||||
def _is_uv_macos_store(path: str) -> bool:
|
||||
normalized = path.replace("\\", "/")
|
||||
if not all(marker in normalized for marker in _STORE_COMMON_MARKERS):
|
||||
return False
|
||||
return any(marker in normalized for marker in _STORE_ROOT_MARKERS)
|
||||
|
||||
|
||||
def _venv_dir(project_root: Path | None = None) -> Path | None:
|
||||
root = (
|
||||
Path(project_root)
|
||||
if project_root is not None
|
||||
else Path(__file__).resolve().parents[1]
|
||||
)
|
||||
for name in ("venv", ".venv"):
|
||||
candidate = root / name
|
||||
venv_py = venv_python_path(candidate)
|
||||
if venv_py.is_file() or venv_py.is_symlink():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _interpreter_file(src: str | Path) -> Path | None:
|
||||
"""Return the interpreter binary file at/inside *src*."""
|
||||
p = Path(src)
|
||||
if p.is_file():
|
||||
return p
|
||||
if not p.is_dir():
|
||||
return None
|
||||
for name in _store_bin_names():
|
||||
candidate = p / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
try:
|
||||
for candidate in sorted(p.glob("python3.*")):
|
||||
if candidate.is_file() and not candidate.name.endswith((".dSYM", ".txt")):
|
||||
return candidate
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _interpreter_source(venv_dir: Path) -> str | None:
|
||||
"""Return the interpreter file the venv currently resolves to."""
|
||||
venv_py = venv_python_path(venv_dir)
|
||||
if venv_py.is_symlink():
|
||||
try:
|
||||
resolved = venv_py.resolve(strict=False)
|
||||
except OSError:
|
||||
return None
|
||||
return str(resolved)
|
||||
cfg = venv_dir / "pyvenv.cfg"
|
||||
if not cfg.is_file():
|
||||
return None
|
||||
home = ""
|
||||
try:
|
||||
for line in cfg.read_text(encoding="utf-8").splitlines():
|
||||
if line.lower().startswith("home"):
|
||||
_, _, home = line.partition("=")
|
||||
home = home.strip()
|
||||
break
|
||||
except OSError:
|
||||
return None
|
||||
if not home:
|
||||
return None
|
||||
interp = _interpreter_file(home)
|
||||
return str(interp) if interp is not None else None
|
||||
|
||||
|
||||
def _anchor_marker(venv_bin: Path) -> Path:
|
||||
return venv_bin / _MARKER_NAME
|
||||
|
||||
|
||||
def _store_root(source_file: Path) -> Path:
|
||||
# .../cpython-<ver>-macos-*/bin/python3.N → store root
|
||||
return source_file.resolve(strict=False).parent.parent
|
||||
|
||||
|
||||
def _provision_libpython(
|
||||
venv_dir: Path, source_file: Path, *, refresh: bool = False
|
||||
) -> None:
|
||||
"""Hardlink (else copy) store ``libpython*`` into ``venv/lib/``.
|
||||
|
||||
Provision-if-present: a surplus hardlink on a statically-linked build is
|
||||
free; a missed detection is the only way #95425 returns.
|
||||
"""
|
||||
src_lib = _store_root(source_file) / "lib"
|
||||
if not src_lib.is_dir():
|
||||
return
|
||||
dst_lib = venv_dir / "lib"
|
||||
try:
|
||||
dst_lib.mkdir(parents=True, exist_ok=True)
|
||||
for src in src_lib.glob("libpython*"):
|
||||
if not src.is_file():
|
||||
continue
|
||||
dst = dst_lib / src.name
|
||||
if dst.exists() or dst.is_symlink():
|
||||
if not refresh:
|
||||
continue
|
||||
try:
|
||||
dst.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except OSError:
|
||||
try:
|
||||
shutil.copy2(src, dst)
|
||||
except OSError:
|
||||
logger.debug("libpython provision failed for %s", src, exc_info=True)
|
||||
except OSError:
|
||||
logger.debug("libpython provision skipped", exc_info=True)
|
||||
|
||||
|
||||
def _copy_alias(venv_bin: Path, name: str, anchor: Path) -> None:
|
||||
"""Materialize *name* as a real-file copy of *anchor* (atomic rename)."""
|
||||
tmp = venv_bin / f".{name}.tcc-tmp"
|
||||
try:
|
||||
shutil.copy2(anchor, tmp)
|
||||
os.chmod(tmp, anchor.stat().st_mode | 0o111)
|
||||
os.replace(tmp, venv_bin / name)
|
||||
except OSError:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _materialize_aliases(
|
||||
venv_bin: Path, anchor: Path, *, refresh: bool = False
|
||||
) -> None:
|
||||
"""Materialize uv alias names as real-file copies of the anchor."""
|
||||
names = set(_sibling_names())
|
||||
try:
|
||||
names.update(
|
||||
p.name
|
||||
for p in venv_bin.glob("python3*")
|
||||
if re.fullmatch(r"python3(\.\d+)?", p.name)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
for name in sorted(names):
|
||||
alias = venv_bin / name
|
||||
try:
|
||||
if refresh or alias.is_symlink() or not alias.exists():
|
||||
_copy_alias(venv_bin, name, anchor)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _passes_boot_gate(staged: Path, venv_dir: Path) -> bool:
|
||||
"""Launch *staged* and demand encodings + the venv prefix.
|
||||
|
||||
``OSError`` (fixture / wrong arch / exec-format) is treated as a skip —
|
||||
we cannot verify, and we also cannot brick a host that cannot run the
|
||||
binary. A real crash (dyld, encodings, wrong prefix) refuses the install.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(staged), "-c", "import encodings, sys; print(sys.prefix)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except OSError:
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
printed = (proc.stdout or "").strip().splitlines()
|
||||
if not printed:
|
||||
return False
|
||||
try:
|
||||
return Path(printed[-1]).resolve() == venv_dir.resolve()
|
||||
except OSError:
|
||||
return str(venv_dir) in printed[-1]
|
||||
|
||||
|
||||
def _install_anchor(venv_dir: Path, source_file: Path) -> None:
|
||||
"""Replace ``bin/python`` with a signed copy, gated on a real boot."""
|
||||
venv_py = venv_python_path(venv_dir)
|
||||
venv_bin = venv_py.parent
|
||||
venv_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_provision_libpython(venv_dir, source_file, refresh=True)
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".python-tcc-", dir=str(venv_bin))
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
shutil.copy2(source_file, tmp_path)
|
||||
os.chmod(tmp_path, source_file.stat().st_mode | 0o111)
|
||||
try:
|
||||
from hermes_cli.managed_uv import _macos_sign_managed_python
|
||||
|
||||
_macos_sign_managed_python(tmp_path)
|
||||
except Exception: # pragma: no cover - never block the anchor
|
||||
logger.debug("anchor copy signing skipped", exc_info=True)
|
||||
if not _passes_boot_gate(tmp_path, venv_dir):
|
||||
raise _BootGateFailed(
|
||||
f"staged copy at {tmp_path} failed encodings/prefix probe"
|
||||
)
|
||||
os.replace(tmp_path, venv_py)
|
||||
_anchor_marker(venv_bin).write_text(str(source_file), encoding="utf-8")
|
||||
_materialize_aliases(venv_bin, venv_py, refresh=True)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None:
|
||||
"""Pin a dylib-complete interpreter anchor for macOS TCC (#95596).
|
||||
|
||||
No-op (returns None) on non-macOS, when no venv interpreter exists, or
|
||||
when the interpreter is not uv-managed. Idempotent. Best-effort —
|
||||
returns None (and logs) if the copy or boot-gate fails; callers must
|
||||
never depend on success.
|
||||
"""
|
||||
if not is_macos():
|
||||
return None
|
||||
venv_dir = _venv_dir(project_root)
|
||||
if venv_dir is None:
|
||||
return None
|
||||
venv_py = venv_python_path(venv_dir)
|
||||
if not (venv_py.is_file() or venv_py.is_symlink()):
|
||||
return None
|
||||
source = _interpreter_source(venv_dir)
|
||||
if source is None or not _is_uv_macos_store(source):
|
||||
return None
|
||||
source_file = _interpreter_file(source)
|
||||
if source_file is None:
|
||||
return None
|
||||
if not venv_py.is_symlink():
|
||||
marker = _anchor_marker(venv_py.parent)
|
||||
try:
|
||||
if marker.is_file() and marker.read_text(encoding="utf-8").strip() == str(
|
||||
source_file
|
||||
):
|
||||
_provision_libpython(venv_dir, source_file, refresh=False)
|
||||
if _passes_boot_gate(venv_py, venv_dir):
|
||||
_materialize_aliases(venv_py.parent, venv_py)
|
||||
return venv_py
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_install_anchor(venv_dir, source_file)
|
||||
except _BootGateFailed as exc:
|
||||
logger.warning("macOS TCC anchor boot-gate refused install: %s", exc)
|
||||
return None
|
||||
except Exception as exc: # best-effort: never break update/doctor
|
||||
logger.warning("macOS TCC anchor install failed: %s", exc)
|
||||
return None
|
||||
return venv_py
|
||||
|
||||
|
||||
def tcc_anchor_state(project_root: Path | None = None) -> tuple[str, str]:
|
||||
"""Report the anchor state for ``hermes doctor``.
|
||||
|
||||
Returns ``(status, detail)`` with status one of:
|
||||
|
||||
- ``"skip"`` — not applicable (non-macOS, no venv, or not uv-managed)
|
||||
- ``"active"`` — venv interpreter is pinned at a stable real-file anchor
|
||||
- ``"stale"`` — pinned but the interpreter changed since the last copy
|
||||
- ``"missing"`` — uv-managed interpreter with no stable anchor installed
|
||||
"""
|
||||
if not is_macos():
|
||||
return "skip", "not macOS"
|
||||
venv_dir = _venv_dir(project_root)
|
||||
if venv_dir is None:
|
||||
return "skip", "no venv interpreter"
|
||||
venv_py = venv_python_path(venv_dir)
|
||||
if not (venv_py.is_file() or venv_py.is_symlink()):
|
||||
return "skip", "no venv interpreter"
|
||||
source = _interpreter_source(venv_dir)
|
||||
if source is None or not _is_uv_macos_store(source):
|
||||
return "skip", "interpreter not uv-managed (stable path)"
|
||||
if not venv_py.is_symlink():
|
||||
marker = _anchor_marker(venv_py.parent)
|
||||
source_file = _interpreter_file(source)
|
||||
expected = str(source_file) if source_file is not None else source
|
||||
try:
|
||||
if marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected:
|
||||
return "active", str(venv_py)
|
||||
except OSError:
|
||||
pass
|
||||
return "stale", str(venv_py)
|
||||
return "missing", str(venv_py)
|
||||
@@ -8467,13 +8467,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"fully quit & relaunch once."
|
||||
)
|
||||
|
||||
# NOTE: the macOS TCC interpreter anchor that used to refresh here
|
||||
# (#95131/#95478) is REVERTED: the anchored real-file copy could not
|
||||
# load libpython (LC_RPATH resolved into venv/lib/), bricking every
|
||||
# hermes command on real Macs (#95425), and re-pointed aliases lost
|
||||
# the stdlib (#95541). `hermes doctor` now heals already-anchored
|
||||
# venvs back to symlinks. Re-land requires a dylib-complete design
|
||||
# verified on macOS hardware first.
|
||||
# macOS TCC interpreter anchor (#95596): dylib-complete re-land.
|
||||
# Boot-gated — a failed probe leaves the venv untouched.
|
||||
try:
|
||||
from hermes_cli.macos_tcc_anchor import ensure_tcc_anchor
|
||||
|
||||
ensure_tcc_anchor()
|
||||
except Exception:
|
||||
logger.debug("macOS TCC anchor refresh skipped", exc_info=True)
|
||||
|
||||
# ── Post-update state.db integrity guard (#68474) ─────────────────
|
||||
# Verify that state.db survived the update intact. If the live file
|
||||
|
||||
452
tests/hermes_cli/test_macos_tcc_anchor.py
Normal file
452
tests/hermes_cli/test_macos_tcc_anchor.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""Tests for the dylib-complete macOS TCC anchor (issue #95596).
|
||||
|
||||
Re-land of the interpreter anchor reverted in #95563. The first landing
|
||||
bricked real Macs two ways: dynamically-linked builds died in dyld because
|
||||
``@executable_path/../lib/libpython`` resolved into ``venv/lib/`` (#95425),
|
||||
and alias symlinks to the copied interpreter lost the venv prefix (#95541).
|
||||
|
||||
Linux tests use fake checkout/uv-store layouts with ``platform.system``
|
||||
monkeypatched. The real-interpreter E2E is ``macos_only`` so it runs on
|
||||
the existing macOS CI job, not against one-byte fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.doctor as doctor
|
||||
import hermes_cli.macos_tcc_anchor as tcc
|
||||
from hermes_constants import venv_python_path
|
||||
|
||||
|
||||
def _darwin(monkeypatch):
|
||||
monkeypatch.setattr(tcc.platform, "system", lambda: "Darwin")
|
||||
|
||||
|
||||
def _linux(monkeypatch):
|
||||
monkeypatch.setattr(tcc.platform, "system", lambda: "Linux")
|
||||
|
||||
|
||||
def _build_store(tmp_path, version: str = "3.11.15", *, with_libpython: bool = False) -> Path:
|
||||
store = (
|
||||
tmp_path
|
||||
/ "uv-store"
|
||||
/ "uv"
|
||||
/ "python"
|
||||
/ f"cpython-{version}-macos-aarch64-none"
|
||||
)
|
||||
store_bin = store / "bin"
|
||||
store_bin.mkdir(parents=True)
|
||||
store_py = store_bin / "python3.11"
|
||||
store_py.write_bytes(f"#!fake interpreter {version}".encode())
|
||||
store_py.chmod(0o755)
|
||||
if with_libpython:
|
||||
lib = store / "lib"
|
||||
lib.mkdir(parents=True)
|
||||
(lib / "libpython3.11.dylib").write_bytes(b"fake dylib")
|
||||
return store_bin
|
||||
|
||||
|
||||
def _build_checkout(
|
||||
tmp_path,
|
||||
*,
|
||||
store_bin: Path | None = None,
|
||||
version: str = "3.11.15",
|
||||
anchored: bool = False,
|
||||
homebrew: bool = False,
|
||||
with_libpython: bool = False,
|
||||
) -> Path:
|
||||
root = tmp_path / "checkout"
|
||||
venv = root / ".venv"
|
||||
venv_bin = venv / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
if homebrew:
|
||||
brew = tmp_path / "opt" / "homebrew" / "bin"
|
||||
brew.mkdir(parents=True)
|
||||
brew_py = brew / "python3.14"
|
||||
brew_py.write_bytes(b"#!homebrew")
|
||||
brew_py.chmod(0o755)
|
||||
(venv / "pyvenv.cfg").write_text(f"home = {brew}\n")
|
||||
os.symlink(brew_py, venv_bin / "python")
|
||||
os.symlink(brew_py, venv_bin / "python3")
|
||||
return root
|
||||
if store_bin is None:
|
||||
store_bin = _build_store(tmp_path, version, with_libpython=with_libpython)
|
||||
(venv / "pyvenv.cfg").write_text(f"home = {store_bin}\n")
|
||||
store_py = store_bin / "python3.11"
|
||||
if anchored:
|
||||
venv_py = venv_bin / "python"
|
||||
venv_py.write_bytes(store_py.read_bytes())
|
||||
venv_py.chmod(0o755)
|
||||
(venv_bin / ".tcc-anchor-source").write_text(str(store_py), encoding="utf-8")
|
||||
os.symlink(venv_py, venv_bin / "python3")
|
||||
else:
|
||||
os.symlink(store_py, venv_bin / "python")
|
||||
os.symlink(store_py, venv_bin / "python3")
|
||||
return root
|
||||
|
||||
|
||||
class TestUvStoreDetection:
|
||||
def test_matches_uv_macos_store_path(self):
|
||||
path = (
|
||||
"/Users/u/.local/share/uv/python/"
|
||||
"cpython-3.11.15-macos-aarch64-none/bin/python3.11"
|
||||
)
|
||||
assert tcc._is_uv_macos_store(path)
|
||||
|
||||
def test_matches_hermes_runtime_repair_generation(self):
|
||||
path = (
|
||||
"/Users/u/hermes-agent/.hermes-runtime/python/"
|
||||
"generation-a1b2c3/cpython-3.11.15-macos-aarch64-none/bin/python3.11"
|
||||
)
|
||||
assert tcc._is_uv_macos_store(path)
|
||||
|
||||
def test_rejects_homebrew_interpreter(self):
|
||||
path = (
|
||||
"/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/"
|
||||
"Python.framework/Versions/3.14/bin/python3.14"
|
||||
)
|
||||
assert not tcc._is_uv_macos_store(path)
|
||||
|
||||
def test_rejects_linux_interpreter(self):
|
||||
assert not tcc._is_uv_macos_store("/usr/bin/python3")
|
||||
|
||||
def test_rejects_uv_store_on_linux(self):
|
||||
path = (
|
||||
"/home/u/.local/share/uv/python/"
|
||||
"cpython-3.11.15-x86_64-unknown-linux-gnu/bin/python3.11"
|
||||
)
|
||||
assert not tcc._is_uv_macos_store(path)
|
||||
|
||||
|
||||
class TestEnsureTccAnchor:
|
||||
def test_noop_on_non_macos(self, tmp_path, monkeypatch):
|
||||
_linux(monkeypatch)
|
||||
root = _build_checkout(tmp_path, store_bin=_build_store(tmp_path))
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
|
||||
assert tcc.ensure_tcc_anchor(root) is None
|
||||
assert venv_py.is_symlink()
|
||||
|
||||
def test_install_signs_the_anchor_copy(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
signed = []
|
||||
import hermes_cli.managed_uv as managed_uv
|
||||
|
||||
monkeypatch.setattr(
|
||||
managed_uv, "_macos_sign_managed_python", lambda p: signed.append(Path(p)) or True
|
||||
)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert anchored is not None
|
||||
assert len(signed) == 1
|
||||
assert signed[0].parent == anchored.parent
|
||||
|
||||
def test_anchors_repair_generation_interpreter(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store = (
|
||||
tmp_path
|
||||
/ "checkout"
|
||||
/ ".hermes-runtime"
|
||||
/ "python"
|
||||
/ "generation-a1b2c3"
|
||||
/ "cpython-3.11.15-macos-aarch64-none"
|
||||
)
|
||||
store_bin = store / "bin"
|
||||
store_bin.mkdir(parents=True)
|
||||
store_py = store_bin / "python3.11"
|
||||
store_py.write_bytes(b"#!fake generation interpreter")
|
||||
store_py.chmod(0o755)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
assert venv_py.is_symlink()
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert anchored == venv_py
|
||||
assert not venv_py.is_symlink()
|
||||
assert venv_py.read_bytes() == store_py.read_bytes()
|
||||
|
||||
def test_anchors_uv_managed_interpreter(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
assert venv_py.is_symlink()
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert anchored == venv_py
|
||||
assert venv_py.is_file() and not venv_py.is_symlink()
|
||||
assert venv_py.read_bytes() == (store_bin / "python3.11").read_bytes()
|
||||
assert os.access(venv_py, os.X_OK)
|
||||
marker = venv_py.parent / ".tcc-anchor-source"
|
||||
assert marker.read_text(encoding="utf-8").strip() == str(
|
||||
store_bin / "python3.11"
|
||||
)
|
||||
alias = venv_py.parent / "python3"
|
||||
assert alias.is_file() and not alias.is_symlink()
|
||||
assert alias.read_bytes() == venv_py.read_bytes()
|
||||
|
||||
def test_idempotent(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin, anchored=True)
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
marker = venv_py.parent / ".tcc-anchor-source"
|
||||
before = marker.read_text(encoding="utf-8")
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert anchored == venv_py
|
||||
assert venv_py.is_file() and not venv_py.is_symlink()
|
||||
assert marker.read_text(encoding="utf-8") == before
|
||||
|
||||
def test_repairs_alias_symlinks_left_by_predecessor(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin, anchored=True)
|
||||
venv_bin = root / ".venv" / "bin"
|
||||
venv_py = venv_bin / "python"
|
||||
assert (venv_bin / "python3").is_symlink()
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert anchored == venv_py
|
||||
alias = venv_bin / "python3"
|
||||
assert alias.is_file() and not alias.is_symlink()
|
||||
assert alias.read_bytes() == venv_py.read_bytes()
|
||||
|
||||
def test_reanchors_after_patch_bump(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
old_bin = _build_store(tmp_path, version="3.11.15")
|
||||
root = _build_checkout(tmp_path, store_bin=old_bin, anchored=True)
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
|
||||
new_bin = _build_store(tmp_path, version="3.11.16")
|
||||
new_py = new_bin / "python3.11"
|
||||
venv_py.unlink()
|
||||
os.symlink(new_py, venv_py)
|
||||
(root / ".venv" / "pyvenv.cfg").write_text(f"home = {new_bin}\n")
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert anchored == venv_py
|
||||
assert not venv_py.is_symlink()
|
||||
assert venv_py.read_bytes() == new_py.read_bytes()
|
||||
marker = venv_py.parent / ".tcc-anchor-source"
|
||||
assert marker.read_text(encoding="utf-8").strip() == str(new_py)
|
||||
alias = venv_py.parent / "python3"
|
||||
assert alias.is_file() and not alias.is_symlink()
|
||||
assert alias.read_bytes() == new_py.read_bytes()
|
||||
|
||||
def test_skips_homebrew_interpreter(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
root = _build_checkout(tmp_path, homebrew=True)
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
|
||||
assert tcc.ensure_tcc_anchor(root) is None
|
||||
assert venv_py.is_symlink()
|
||||
|
||||
def test_no_venv_returns_none(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
assert tcc.ensure_tcc_anchor(tmp_path / "missing") is None
|
||||
|
||||
def test_preserves_stdlib_source_home(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
cfg = root / ".venv" / "pyvenv.cfg"
|
||||
|
||||
tcc.ensure_tcc_anchor(root)
|
||||
|
||||
assert f"home = {store_bin}" in cfg.read_text(encoding="utf-8")
|
||||
|
||||
def test_provisions_libpython_as_hardlink_when_present(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path, with_libpython=True)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
src_dylib = store_bin.parent / "lib" / "libpython3.11.dylib"
|
||||
|
||||
tcc.ensure_tcc_anchor(root)
|
||||
|
||||
dst = root / ".venv" / "lib" / "libpython3.11.dylib"
|
||||
assert dst.is_file()
|
||||
assert dst.read_bytes() == src_dylib.read_bytes()
|
||||
assert dst.stat().st_ino == src_dylib.stat().st_ino
|
||||
|
||||
def test_boot_gate_refusal_leaves_venv_untouched(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
venv_py = venv_python_path(root / ".venv")
|
||||
monkeypatch.setattr(tcc, "_passes_boot_gate", lambda *a, **k: False)
|
||||
|
||||
assert tcc.ensure_tcc_anchor(root) is None
|
||||
assert venv_py.is_symlink()
|
||||
assert not (venv_py.parent / ".tcc-anchor-source").exists()
|
||||
|
||||
|
||||
class TestTccAnchorState:
|
||||
def test_state_missing_then_active(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
|
||||
status, detail = tcc.tcc_anchor_state(root)
|
||||
assert status == "missing"
|
||||
assert str(venv_python_path(root / ".venv")) in detail
|
||||
|
||||
tcc.ensure_tcc_anchor(root)
|
||||
|
||||
status, detail = tcc.tcc_anchor_state(root)
|
||||
assert status == "active"
|
||||
|
||||
def test_state_skip_on_linux(self, tmp_path, monkeypatch):
|
||||
_linux(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
root = _build_checkout(tmp_path, store_bin=store_bin)
|
||||
status, detail = tcc.tcc_anchor_state(root)
|
||||
assert status == "skip"
|
||||
assert detail == "not macOS"
|
||||
|
||||
def test_state_skip_for_homebrew(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
root = _build_checkout(tmp_path, homebrew=True)
|
||||
status, detail = tcc.tcc_anchor_state(root)
|
||||
assert status == "skip"
|
||||
assert "not uv-managed" in detail
|
||||
|
||||
def test_state_stale_after_patch_bump(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
old_bin = _build_store(tmp_path, version="3.11.15")
|
||||
root = _build_checkout(tmp_path, store_bin=old_bin, anchored=True)
|
||||
new_bin = _build_store(tmp_path, version="3.11.16")
|
||||
(root / ".venv" / "pyvenv.cfg").write_text(f"home = {new_bin}\n")
|
||||
status, _ = tcc.tcc_anchor_state(root)
|
||||
assert status == "stale"
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
assert anchored == venv_python_path(root / ".venv")
|
||||
assert (root / ".venv" / "bin" / "python").read_bytes() == (
|
||||
new_bin / "python3.11"
|
||||
).read_bytes()
|
||||
status, _ = tcc.tcc_anchor_state(root)
|
||||
assert status == "active"
|
||||
|
||||
|
||||
class TestDoctorCheck:
|
||||
def test_missing_warns_without_fix(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
tcc, "tcc_anchor_state", lambda *a, **k: ("missing", "/x/.venv/bin/python")
|
||||
)
|
||||
doctor.check_macos_tcc_anchor(should_fix=False)
|
||||
out = capsys.readouterr().out
|
||||
assert "macOS TCC anchor missing" in out
|
||||
|
||||
def test_fix_installs_anchor(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
tcc, "tcc_anchor_state", lambda *a, **k: ("missing", "/x/.venv/bin/python")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tcc, "ensure_tcc_anchor", lambda *a, **k: Path("/x/.venv/bin/python")
|
||||
)
|
||||
doctor.check_macos_tcc_anchor(should_fix=True)
|
||||
out = capsys.readouterr().out
|
||||
assert "macOS TCC anchor installed" in out
|
||||
|
||||
def test_active_reports_ok(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
tcc, "tcc_anchor_state", lambda *a, **k: ("active", "/x/.venv/bin/python")
|
||||
)
|
||||
doctor.check_macos_tcc_anchor(should_fix=False)
|
||||
out = capsys.readouterr().out
|
||||
assert "macOS TCC anchor active" in out
|
||||
|
||||
def test_skip_is_silent_on_non_macos(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
tcc, "tcc_anchor_state", lambda *a, **k: ("skip", "not macOS")
|
||||
)
|
||||
doctor.check_macos_tcc_anchor(should_fix=False)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_never_crashes_on_exception(self, monkeypatch, capsys):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("tccd down")
|
||||
|
||||
monkeypatch.setattr(tcc, "tcc_anchor_state", boom)
|
||||
doctor.check_macos_tcc_anchor(should_fix=False)
|
||||
out = capsys.readouterr().out
|
||||
assert "macOS TCC anchor check failed" in out
|
||||
|
||||
|
||||
@pytest.mark.macos_only
|
||||
class TestAnchoredAliasesBootE2E:
|
||||
"""Real-interpreter proof that the re-land stays bootable (#95596).
|
||||
|
||||
Copies the running interpreter's real base binary into a fake uv-store
|
||||
layout (stdlib via a ``lib`` symlink) and actually executes every
|
||||
entry point after anchoring. ``macos_only`` so Linux CI cannot
|
||||
greenwash this with a fixture.
|
||||
"""
|
||||
|
||||
def test_python_entry_points_boot_after_anchor(self, tmp_path):
|
||||
minor = f"python3.{sys.version_info.minor}"
|
||||
base = Path(sys.base_prefix)
|
||||
real_py = base / "bin" / minor
|
||||
if not real_py.is_file() or real_py.is_symlink():
|
||||
resolved = real_py.resolve() if real_py.exists() else None
|
||||
if resolved is None or not resolved.is_file():
|
||||
pytest.skip(f"no real base interpreter binary at {real_py}")
|
||||
real_py = resolved
|
||||
if not (base / "lib" / minor / "os.py").is_file():
|
||||
pytest.skip("base stdlib not in the expected lib layout")
|
||||
|
||||
store = (
|
||||
tmp_path
|
||||
/ "uv"
|
||||
/ "python"
|
||||
/ f"cpython-{platform.python_version()}-macos-aarch64-none"
|
||||
)
|
||||
store_bin = store / "bin"
|
||||
store_bin.mkdir(parents=True)
|
||||
shutil.copy2(real_py, store_bin / minor)
|
||||
os.symlink(base / "lib", store / "lib")
|
||||
|
||||
root = tmp_path / "checkout"
|
||||
venv = root / ".venv"
|
||||
venv_bin = venv / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv / "lib" / minor / "site-packages").mkdir(parents=True)
|
||||
(venv / "pyvenv.cfg").write_text(
|
||||
f"home = {store_bin}\nversion = {platform.python_version()}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.symlink(store_bin / minor, venv_bin / "python")
|
||||
os.symlink("python", venv_bin / "python3")
|
||||
os.symlink("python", venv_bin / minor)
|
||||
|
||||
anchored = tcc.ensure_tcc_anchor(root)
|
||||
assert anchored is not None
|
||||
|
||||
for name in ("python", "python3", minor):
|
||||
probe = subprocess.run(
|
||||
[str(venv_bin / name), "-c",
|
||||
"import encodings, sys; print(sys.prefix)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert probe.returncode == 0, (
|
||||
f"{name} failed to boot after anchoring:\n{probe.stderr}"
|
||||
)
|
||||
assert str(venv) in probe.stdout
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Tests for the TCC-anchor revert heal (#95425 / #95541).
|
||||
|
||||
The interpreter anchor replaced venv/bin/python with a real-file copy that
|
||||
could not load libpython on real Macs, bricking the CLI. The anchor is
|
||||
reverted; doctor's check_macos_tcc_anchor_removed() restores anchored venvs
|
||||
to symlinks using the marker the anchor left behind.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.doctor as doctor_mod
|
||||
|
||||
|
||||
def _capture(fn):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
fn()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _build_anchored_checkout(tmp_path):
|
||||
"""A checkout whose venv the anchor converted: real-file python + marker."""
|
||||
root = tmp_path / "checkout"
|
||||
store_bin = tmp_path / "store" / "cpython-3.12.1-macos" / "bin"
|
||||
store_bin.mkdir(parents=True)
|
||||
source = store_bin / "python3.12"
|
||||
source.write_bytes(b"#!store interpreter")
|
||||
source.chmod(0o755)
|
||||
venv_bin = root / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
venv_py = venv_bin / "python"
|
||||
venv_py.write_bytes(b"#!anchored copy (broken on real macs)")
|
||||
venv_py.chmod(0o755)
|
||||
(venv_bin / ".tcc-anchor-source").write_text(str(source), encoding="utf-8")
|
||||
os.symlink(venv_py, venv_bin / "python3")
|
||||
return root, source, venv_py
|
||||
|
||||
|
||||
def test_silent_on_non_macos(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(doctor_mod.sys, "platform", "linux")
|
||||
assert _capture(doctor_mod.check_macos_tcc_anchor_removed) == ""
|
||||
|
||||
|
||||
def test_silent_when_never_anchored(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(doctor_mod.sys, "platform", "darwin")
|
||||
root = tmp_path / "checkout"
|
||||
(root / "venv" / "bin").mkdir(parents=True)
|
||||
monkeypatch.setattr(
|
||||
doctor_mod, "__file__", str(root / "hermes_cli" / "doctor.py")
|
||||
)
|
||||
|
||||
out = _capture(doctor_mod.check_macos_tcc_anchor_removed)
|
||||
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_heals_anchored_venv(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(doctor_mod.sys, "platform", "darwin")
|
||||
root, source, venv_py = _build_anchored_checkout(tmp_path)
|
||||
|
||||
# Point the check's root resolution at the fixture checkout.
|
||||
monkeypatch.setattr(
|
||||
doctor_mod, "__file__", str(root / "hermes_cli" / "doctor.py")
|
||||
)
|
||||
|
||||
out = _capture(doctor_mod.check_macos_tcc_anchor_removed)
|
||||
|
||||
assert "TCC anchor removed" in out
|
||||
assert venv_py.is_symlink()
|
||||
assert Path(os.readlink(venv_py)) == source
|
||||
assert not (venv_py.parent / ".tcc-anchor-source").exists()
|
||||
# Aliases restored to point at bin/python.
|
||||
alias = venv_py.parent / "python3"
|
||||
assert alias.is_symlink()
|
||||
assert os.readlink(alias) == "python"
|
||||
Reference in New Issue
Block a user