Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions roar/cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
Usage: roar init
"""

import os
import shutil
import sqlite3 as _sqlite3
import sys
from pathlib import Path

import click
Expand Down Expand Up @@ -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:
# <prefix>/bin/python -> <prefix>
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."""
Expand Down Expand Up @@ -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 <backend>`;")
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.")
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_cli_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading