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