From da732612cbe50e7448c48bfa4ac9f372702746bc Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Wed, 19 Aug 2026 15:03:19 +0000 Subject: [PATCH] feat: recommend a separate virtual environment for roar at init Installing roar into the workload's own environment has two costs, and the hint names both rather than dwelling on either. Both sets of requirements must resolve together, so roar's pins can collide with the project's. And roar's dependencies are loaded into the traced process and recorded alongside the project's -- measured on an `import requests` workload, the freeze carries nine packages belonging to roar. They cannot be separated afterwards: roar's copy of a package and the workload's are the same file at the same path, so nothing -- path, name, or dist metadata -- can attribute them. Subtracting by name once stripped the workload's own tqdm and typing-extensions (P0-28), which is why `roar_footprint_paths` abstains and the freeze over-includes instead. #287 tried to fix the second cost by snapshotting sys.modules at the end of bootstrap and subtracting it. That is measurably inert -- roar's dependencies load lazily *after* the boundary -- while risking a real false negative, so it was closed in favour of saying this plainly. The comparison is against the interpreter the WORKLOAD would use, not roar's own. That distinction is the whole check: under `uv tool` or pipx, roar runs from its own venv and so always sits inside its own sys.prefix, so comparing roar against itself reports every correctly isolated install as shared -- nagging exactly the users who took the advice. Resolution mirrors a shell's: active virtualenv, else conda env, else the first python on PATH; unresolvable means stay quiet. Verified live in both layouts rather than by assumption: roar copied into a project venv warns; roar in its own venv with a project venv active stays silent; and with no venv active, a tool install still stays silent because the workload would run the system python. Printed once at `roar init`, not per run: it is a property of how roar was installed, and a per-run warning is noise people learn to skip. It rides the existing hint machinery, so `roar config set hints.enabled false` already silences it. Both uv and pipx are offered, with install routes, since not everyone has uv. Seven tests over the real layouts (pip-into-venv, tool-install with and without an active venv, system install, conda), each verified to fail against the naive roar-vs-its-own-prefix comparison. Detection cannot fail the command; a cosmetic hint is never worth breaking `roar init` for. Co-Authored-By: Claude Opus 5 (1M context) --- roar/cli/commands/init.py | 58 ++++++++++++++++++++++++++++++ tests/unit/test_cli_init.py | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/roar/cli/commands/init.py b/roar/cli/commands/init.py index 9e45ce1d..324179c8 100644 --- a/roar/cli/commands/init.py +++ b/roar/cli/commands/init.py @@ -4,7 +4,10 @@ Usage: roar init """ +import os +import shutil import sqlite3 as _sqlite3 +import sys from pathlib import Path import click @@ -357,6 +360,54 @@ def _print_version_header() -> None: print_brand_header("init") +def roar_shares_this_environment() -> bool: + """Whether roar is installed into the environment a workload would run in. + + Sharing one environment means roar's requirements and the project's have to + resolve together, and roar's dependencies are loaded into the traced process + and recorded alongside the project's. They cannot be told apart afterwards: + roar's copy of a package and the workload's are the same file at the same + path, so nothing -- path, name, or dist metadata -- can attribute them. + Subtracting by name once stripped the workload's own tqdm and + typing-extensions, so the freeze over-includes instead (see + ``roar_footprint_paths``). Better to recommend separate environments up + front than to discover either problem in a published record. + """ + try: + workload_prefix = _workload_interpreter_prefix() + if workload_prefix is None: + return False + return os.path.abspath(sys.prefix) == workload_prefix + except Exception: + # Never let a cosmetic hint break `roar init`. + return False + + +def _workload_interpreter_prefix() -> str | None: + """The prefix of the interpreter ``roar run python ...`` would use. + + Deliberately NOT ``sys.prefix``: that is *roar's* interpreter. Under a + ``uv tool`` or pipx install roar runs from its own venv, so roar always sits + under its own prefix and comparing the two would report every correctly + isolated install as shared -- nagging exactly the people who took the advice. + + Resolution mirrors what a shell would do: the active virtualenv or conda + env, else the first ``python`` on PATH. Returns None when no interpreter can + be resolved, which is treated as "say nothing". + """ + for env_var in ("VIRTUAL_ENV", "CONDA_PREFIX"): + value = os.environ.get(env_var) + if value: + return os.path.abspath(value) + + for name in ("python3", "python"): + found = shutil.which(name) + if found: + # /bin/python -> + return os.path.abspath(os.path.dirname(os.path.dirname(os.path.realpath(found)))) + return None + + def _maybe_print_init_hints(*, in_git_repo: bool, gitignore_action: str | None) -> None: """Print git-style `hint:` lines for next steps. Amber-colored to match git's hint convention. Suppressed in quiet/non-TTY contexts.""" @@ -384,6 +435,13 @@ def _maybe_print_init_hints(*, in_git_repo: bool, gitignore_action: str | None) hint() hint("Tracer auto-selects (eBPF → preload → ptrace). Switch with `roar tracer `;") hint("see all backends and readiness with `roar tracer`.") + if roar_shares_this_environment(): + hint() + hint("roar is installed in the same environment as your project. We recommend") + hint("running roar from its own virtual environment: it prevents version") + hint("conflicts and keeps them out of your lineage.") + hint(" uv tool install roar-cli # uv: https://astral.sh/uv") + hint(" pipx install roar-cli # pipx: sudo apt install pipx | brew install pipx") if in_git_repo: hint() hint("`roar run` requires a clean git tree — runs are tagged with the commit SHA.") diff --git a/tests/unit/test_cli_init.py b/tests/unit/test_cli_init.py index a89f2f22..a7e90a7f 100644 --- a/tests/unit/test_cli_init.py +++ b/tests/unit/test_cli_init.py @@ -301,3 +301,75 @@ def test_init_path_uses_target_repo_for_gitignore_updates(tmp_path: Path) -> Non assert caller_gitignore.read_text() == ".roar/\n" assert ".roar/" in target_gitignore.read_text().splitlines() assert (target_repo / ".roar").is_dir() + + +class TestSharedEnvironmentDetection: + """roar sharing the workload's environment means both sets of requirements + must resolve together, and roar's dependencies land in the freeze where + nothing can attribute them (P0-28). The comparison must be against the + interpreter the WORKLOAD would use, not roar's own: under `uv tool` or pipx + roar always sits inside its own prefix, so comparing roar to itself reports + every correctly isolated install as shared -- nagging exactly the users who + took the advice.""" + + def _prefixes(self, monkeypatch, *, roar_prefix, venv=None, conda=None, path_python=None): + from roar.cli.commands import init as init_module + + monkeypatch.setattr(init_module.sys, "prefix", roar_prefix, raising=False) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.delenv("CONDA_PREFIX", raising=False) + if venv: + monkeypatch.setenv("VIRTUAL_ENV", venv) + if conda: + monkeypatch.setenv("CONDA_PREFIX", conda) + monkeypatch.setattr(init_module.shutil, "which", lambda _name: path_python, raising=False) + return init_module + + def test_pip_installed_into_the_active_project_venv_is_shared(self, monkeypatch): + init_module = self._prefixes(monkeypatch, roar_prefix="/proj/.venv", venv="/proj/.venv") + assert init_module.roar_shares_this_environment() is True + + def test_a_tool_install_alongside_an_active_project_venv_is_isolated(self, monkeypatch): + """The `uv tool` / pipx layout: roar runs from its own venv.""" + init_module = self._prefixes( + monkeypatch, roar_prefix="/home/u/.local/share/uv/tools/roar-cli", venv="/proj/.venv" + ) + assert init_module.roar_shares_this_environment() is False + + def test_a_tool_install_with_no_venv_active_is_isolated(self, monkeypatch): + """No venv: the workload would run the system python, which is not roar's.""" + init_module = self._prefixes( + monkeypatch, + roar_prefix="/home/u/.local/share/uv/tools/roar-cli", + path_python="/usr/bin/python3", + ) + assert init_module.roar_shares_this_environment() is False + + def test_a_system_install_with_no_venv_is_shared(self, monkeypatch): + init_module = self._prefixes( + monkeypatch, roar_prefix="/usr", path_python="/usr/bin/python3" + ) + assert init_module.roar_shares_this_environment() is True + + def test_a_conda_environment_is_honoured(self, monkeypatch): + init_module = self._prefixes( + monkeypatch, roar_prefix="/opt/conda/envs/proj", conda="/opt/conda/envs/proj" + ) + assert init_module.roar_shares_this_environment() is True + + def test_no_resolvable_interpreter_says_nothing(self, monkeypatch): + init_module = self._prefixes(monkeypatch, roar_prefix="/anything", path_python=None) + assert init_module.roar_shares_this_environment() is False + + def test_detection_never_breaks_init(self, monkeypatch): + """A cosmetic hint must not be able to fail `roar init`.""" + from roar.cli.commands import init as init_module + + def _boom(_name): + raise OSError("PATH exploded") + + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.delenv("CONDA_PREFIX", raising=False) + monkeypatch.setattr(init_module.shutil, "which", _boom, raising=False) + + assert init_module.roar_shares_this_environment() is False