diff --git a/AGENTS.md b/AGENTS.md index d563e2473f..8e7529bae9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,7 @@ source ./activate # provisions/syncs PM tools + dependencies, then activates ``` Select an isolated development `HERMES_HOME` and `HERMES_RUNTIME_DIR` first; see `website/docs/reference/package-management.md#developer-workflow`. -PowerShell: `. .\activate.ps1`. `deactivate` restores the prior +fish: `source ./activate.fish`; PowerShell: `. .\activate.ps1`. `deactivate` restores the prior environment. Non-interactive callers run under `scripts/run-in-hermes-env CMD...` instead of sourcing. For tests, use the independent test environment in `CONTRIBUTING.md` (or Nix); PM activation's `PYTHONPATH` does not survive the test runner's environment scrub. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37613a9ca9..7906301f08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,6 +136,13 @@ source ./activate hermes --version ``` +fish: + +```fish +source ./activate.fish +hermes --version +``` + PowerShell: ```powershell diff --git a/activate b/activate index fb2bfdf241..202c817947 100755 --- a/activate +++ b/activate @@ -2,8 +2,9 @@ # ============================================================================ # venv-style activation of the Hermes dev environment for an interactive shell. # -# source ./activate (bash / zsh, repo root) -# .\activate.ps1 (PowerShell) +# source ./activate (bash / zsh, repo root) +# source ./activate.fish (fish) +# .\activate.ps1 (PowerShell) # # Applies the composed pm env (PATH + tool vars) to the CURRENT shell, with # save/restore: `deactivate` undoes exactly what activation changed. @@ -65,7 +66,7 @@ if [ "$(type -t deactivate 2>/dev/null)" = "function" ]; then deactivate fi -_hermes_script="$(hermes_compose_env "$_HERMES_REPO")" || { +_hermes_script="$(hermes_compose_env "$_HERMES_REPO" sh)" || { echo "activate: could not compose the environment (see above)" >&2 unset _hermes_script return 1 2>/dev/null || exit 1 diff --git a/activate.fish b/activate.fish new file mode 100755 index 0000000000..89ea0a74bc --- /dev/null +++ b/activate.fish @@ -0,0 +1,111 @@ +# venv-style activation of the Hermes dev environment for fish. +# +# source ./activate.fish +# +# The fish counterpart of ./activate: applies the composed pm env to the +# CURRENT shell, with save/restore (`deactivate` undoes exactly what activation +# changed), makes `hermes` a function for this checkout, and prefixes the prompt +# with the worktree name. Scripts and other processes run under +# `scripts/run-in-hermes-env` instead. +# +# Options: `--test-extras a,b` selects the test environment's runtime extras +# (default: [all]). +# +# Composition (setup sync, bootstrap Python, pm env) is shared with ./activate +# through scripts/_activation.sh, so this file only applies its output. + +set -l repo (path resolve (path dirname (status filename))) + +argparse 'test-extras=' -- $argv +or return 2 +set -l test_environment --test-environment +if set -q _flag_test_extras + set test_environment --test-environment=$_flag_test_extras +end + +set -l library '. "$1/scripts/_activation.sh"' +bash -c "$library; hermes_sync \"\$1\" \"\$2\"" _ $repo $test_environment >&2 +or begin + echo 'activate.fish: setup failed; shell environment unchanged' >&2 + return 1 +end + +# Re-activating deactivates first, as ./activate does. +functions -q deactivate +and deactivate + +set -l composed (bash -c "$library; hermes_compose_env \"\$1\" fish" _ $repo) +or begin + echo 'activate.fish: could not compose the environment (see above)' >&2 + return 1 +end + +# --- snapshot what we are about to change (deactivate restores this) --- +set -g __hermes_keys (string replace -rf '^set -gx (\S+) .*' '$1' -- $composed) +for name in $__hermes_keys + if set -q $name + set -g __hermes_had_$name 1 + set -g __hermes_prior_$name $$name + end +end +string join \n -- $composed | source + +# This checkout, not whichever `hermes` PATH finds. A function beats PATH. +set -g __hermes_worktree $repo +# The branch names the worktree. A checkout cannot share a branch with another. +set -g __hermes_worktree_name (git -C $repo rev-parse --abbrev-ref HEAD 2>/dev/null) +if test -z "$__hermes_worktree_name" -o "$__hermes_worktree_name" = HEAD + set __hermes_worktree_name (path basename $repo) +end + +function __hermes_worktree_here + set -l top (git rev-parse --show-toplevel 2>/dev/null) + or return 1 + test (path resolve $top) = (path resolve $__hermes_worktree) +end + +function hermes --description 'hermes of the activated checkout' + if not __hermes_worktree_here + echo "hermes: $PWD is outside $__hermes_worktree; refusing (the installed command is hidden while this checkout is active)" >&2 + return 1 + end + set -l python python + if set -q PYTHON + set python $PYTHON + else if test -x $__hermes_worktree/.venv/bin/python + set python $__hermes_worktree/.venv/bin/python + else if test -x $__hermes_worktree/venv/bin/python + set python $__hermes_worktree/venv/bin/python + end + pushd $__hermes_worktree >/dev/null + $python hermes $argv + set -l code $status + popd >/dev/null + return $code +end + +functions -c fish_prompt __hermes_saved_fish_prompt +function fish_prompt + if __hermes_worktree_here + printf '(%s) ' $__hermes_worktree_name + end + __hermes_saved_fish_prompt +end + +function deactivate --description 'undo activate.fish' + for name in $__hermes_keys + set -l had __hermes_had_$name + set -l prior __hermes_prior_$name + if set -q $had + set -gx $name $$prior + else + set -eg $name + end + set -eg $had $prior + end + functions -e fish_prompt + functions -c __hermes_saved_fish_prompt fish_prompt + functions -e __hermes_saved_fish_prompt + set -eg __hermes_keys __hermes_worktree __hermes_worktree_name + functions -e deactivate hermes __hermes_worktree_here +end diff --git a/pm/environments.py b/pm/environments.py index 401c18f617..c520fc2e6c 100644 --- a/pm/environments.py +++ b/pm/environments.py @@ -395,25 +395,45 @@ def activation_environment(project_root: Path) -> dict[str, str]: return env +def _fish_quote(value: str) -> str: + """Inside fish single quotes only backslash and the quote itself are special.""" + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'" + + +# fish refuses to assign these, and each is either inherited unchanged from the +# invoking shell (PWD, SHLVL, _) or fish's own state, so skipping loses nothing. +_FISH_READ_ONLY = frozenset({ + "PWD", "SHLVL", "_", "status", "version", "hostname", "fish_pid", "history", + "pipestatus", "status_generation", "umask", "FISH_VERSION", +}) + +# dialect -> (export statement, names the shell cannot assign). fish splits +# values of variables named *PATH on colons when they are set from a single +# word, so PATH stays a list. +_SHELL_DIALECTS = { + "sh": (lambda name, value: f"export {name}={shlex.quote(value)}", frozenset()), + "fish": (lambda name, value: f"set -gx {name} {_fish_quote(value)}", _FISH_READ_ONLY), +} _SHELL_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") -def shell_exports(env: dict[str, str]) -> str: - """The composed environment as a POSIX shell script. +def shell_exports(env: dict[str, str], dialect: str) -> str: + """The composed environment as a script a shell of ``dialect`` can evaluate. Windows carries names like ``ProgramFiles(ARM)`` that no shell can assign; they pass through untouched instead of failing the whole script. """ - return "\n".join(f"export {name}={shlex.quote(str(value))}" for name, value in env.items() - if _SHELL_IDENTIFIER.fullmatch(name)) + statement, read_only = _SHELL_DIALECTS[dialect] + return "\n".join(statement(name, str(value)) for name, value in env.items() + if _SHELL_IDENTIFIER.fullmatch(name) and name not in read_only) def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description="Print the composed activation environment.") - parser.add_argument("--format", choices=["json", "sh"], default="json") + parser.add_argument("--format", choices=["json", *_SHELL_DIALECTS], default="json") options = parser.parse_args(argv) env = activation_environment(Path(__file__).resolve().parents[1]) - print(json.dumps(env) if options.format == "json" else shell_exports(env)) + print(json.dumps(env) if options.format == "json" else shell_exports(env, options.format)) if __name__ == "__main__": diff --git a/scripts/_activation.sh b/scripts/_activation.sh index f00c5224d1..3dd566d4e3 100644 --- a/scripts/_activation.sh +++ b/scripts/_activation.sh @@ -3,8 +3,8 @@ # process, one-way) both build on it; neither owns any of it. # # hermes_sync REPO [FLAG] bring the install up to date (setup-hermes.sh) -# hermes_compose_env REPO print the composed environment as an `sh` script -# hermes_apply_env SCRIPT evaluate that script into this shell +# hermes_compose_env REPO DIALECT print the composed environment, `sh` or `fish` +# hermes_apply_env SCRIPT evaluate a composed `sh` script into this shell # hermes_activation_current REPO is the inherited environment still REPO's? # hermes_ensure_env REPO sync, compose and apply unless it is current # @@ -48,15 +48,15 @@ hermes_bootstrap_python() { return 1 } -# hermes_compose_env REPO: print the environment of the installed state as an -# `sh` script. +# hermes_compose_env REPO DIALECT: print the environment of the installed state +# as a script for a shell of DIALECT (`sh` or `fish`). hermes_compose_env() { - local repo="$1" python script + local repo="$1" dialect="$2" python script python="$(hermes_bootstrap_python "$repo")" || { echo "no bootstrap Python found; run setup-hermes.sh" >&2 return 1 } - script="$(PYTHONHOME= PYTHONPATH="$repo" "$python" -m pm.environments --format sh)" && + script="$(PYTHONHOME= PYTHONPATH="$repo" "$python" -m pm.environments --format "$dialect")" && [ -n "$script" ] || { echo "could not read pm env (run ./setup-hermes.sh first)" >&2 return 1 @@ -64,7 +64,7 @@ hermes_compose_env() { printf '%s\n' "$script" } -# hermes_apply_env SCRIPT: evaluate a script from `hermes_compose_env REPO`. +# hermes_apply_env SCRIPT: evaluate a script from `hermes_compose_env REPO sh`. hermes_apply_env() { # Resolved before the eval replaces PATH. local cygpath name @@ -121,6 +121,6 @@ hermes_ensure_env() { echo "setup failed" >&2 return 1 } - script="$(hermes_compose_env "$repo")" || return 1 + script="$(hermes_compose_env "$repo" sh)" || return 1 hermes_apply_env "$script" } diff --git a/tests/pm/activation_support.py b/tests/pm/activation_support.py index d33bd692d5..a12dfbf404 100644 --- a/tests/pm/activation_support.py +++ b/tests/pm/activation_support.py @@ -21,6 +21,7 @@ from pm.store import current_target REPO_ROOT = Path(__file__).resolve().parents[2] ACTIVATE = REPO_ROOT / "activate" +ACTIVATE_FISH = REPO_ROOT / "activate.fish" ACTIVATE_PS1 = REPO_ROOT / "activate.ps1" SETUP_HERMES_SH = REPO_ROOT / "setup-hermes.sh" SETUP_HERMES_PS1 = REPO_ROOT / "setup-hermes.ps1" @@ -160,7 +161,7 @@ def isolated_checkout(tmp_path: Path) -> Path: shutil.copytree(REPO_ROOT / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__")) (root / "hermes_cli").mkdir() (root / "scripts").mkdir() - for relative in ("activate", "activate.ps1", "scripts/_activation.sh", + for relative in ("activate", "activate.fish", "activate.ps1", "scripts/_activation.sh", "hermes_constants.py", "hermes_cli/__init__.py", "pm/environments.py", "hermes_cli/runtime_state.py"): shutil.copy2(REPO_ROOT / relative, root / relative) diff --git a/tests/pm/test_activate_fish.py b/tests/pm/test_activate_fish.py new file mode 100644 index 0000000000..52325a6bd4 --- /dev/null +++ b/tests/pm/test_activate_fish.py @@ -0,0 +1,85 @@ +"""``activate.fish``: fish takes on the environment and gives it back, as ``activate`` does for bash. + +Runs the real script under a real fish against the shared isolated checkout +(tests/pm/activation_support.py). Skipped where fish is not installed. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.pm.activation_support import fake_store, posix, sync_checkout + +# Spawns children with a home it builds itself; the parent's must stay real. +pytestmark = [ + pytest.mark.real_machine_home, + pytest.mark.platforms("posix"), + pytest.mark.skipif(shutil.which("fish") is None, reason="fish is not installed"), +] + + +def _fish(root: Path, env: dict, script: str, tmp_path: Path) -> subprocess.CompletedProcess: + return subprocess.run( + ["fish", "--no-config", "-c", script.replace("@ACTIVATE@", posix(root / "activate.fish"))], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60, + ) + + +def test_activate_applies_the_environment_and_deactivate_restores_the_shell(tmp_path): + root, env = sync_checkout(tmp_path) + fake_store(tmp_path) + run = _fish(root, env, """ + set -gx PYTHONPATH caller-original + set -gx VIRTUAL_ENV caller-venv + set -l original_path (string join : $PATH) + + source "@ACTIVATE@" + or exit 10 + # The sentinel and the tool variables must reach child processes. + bash -c 'test -n "$__HERMES_ACTIVATED"'; or exit 11 + test (bash -c 'printf %s "$HERMES_PM_ACTIVATE_CANARY"') = env-ok; or exit 12 + # PYTHONPATH stays a list: the checkout first, then the selected environment. + printf '%s\\n' $PYTHONPATH + + # Re-activating deactivates first, so one deactivate undoes everything. + source "@ACTIVATE@" + or exit 13 + deactivate + + test (string join : $PATH) = $original_path; or exit 20 + test "$PYTHONPATH" = caller-original; or exit 21 + test "$VIRTUAL_ENV" = caller-venv; or exit 22 + set -q __HERMES_ACTIVATED; and exit 23 + set -q HERMES_PM_ACTIVATE_CANARY; and exit 24 + functions -q deactivate; and exit 25 + functions -q hermes; and exit 26 + functions -q __hermes_saved_fish_prompt; and exit 27 + exit 0 + """, tmp_path) + assert run.returncode == 0, run.stdout + run.stderr + assert run.stdout.splitlines()[0] == str(root) + assert "/first/venv/" in run.stdout.splitlines()[1] + + +def test_setup_failure_leaves_the_caller_unchanged(tmp_path): + root, env = sync_checkout(tmp_path) + fake_store(tmp_path) + (root / "fail").touch() + run = _fish(root, env, """ + set -gx PYTHONPATH caller-path + set -l before (env | sort | string collect) + + source "@ACTIVATE@" + set -l code $status + test $code -ne 0; or exit 10 + test (env | sort | string collect) = "$before"; or exit 11 + set -q __HERMES_ACTIVATED; and exit 12 + functions -q deactivate; and exit 13 + exit 0 + """, tmp_path) + assert run.returncode == 0, run.stdout + run.stderr + assert "setup failed" in run.stderr diff --git a/tests/pm/test_activate_scripts.py b/tests/pm/test_activate_scripts.py index 1f65dd1a23..26ed40d1bb 100644 --- a/tests/pm/test_activate_scripts.py +++ b/tests/pm/test_activate_scripts.py @@ -2,7 +2,7 @@ What a process launched under ``scripts/run-in-hermes-env`` sees is covered in tests/scripts/test_run_in_hermes_env.py; how the environment is composed, in -test_environment_script.py. +test_environment_script.py; the fish port, in test_activate_fish.py. """ from __future__ import annotations diff --git a/tests/pm/test_environment_script.py b/tests/pm/test_environment_script.py index c045bded14..2977596146 100644 --- a/tests/pm/test_environment_script.py +++ b/tests/pm/test_environment_script.py @@ -1,12 +1,14 @@ """``pm.environments.shell_exports``: the composed environment as a script a shell can evaluate. -Both ``activate`` and ``scripts/run-in-hermes-env`` evaluate this output, so the -contract is what a real shell reads back: any value survives the quoting, and -names the shell cannot assign are left out instead of failing the whole script. +Every shell that takes on the environment (``activate``, ``activate.fish``, +``scripts/run-in-hermes-env``) evaluates this output, so the contract is what a +real shell reads back: any value survives its dialect's quoting, and names the +shell cannot assign are left out instead of failing the whole script. """ from __future__ import annotations +import shutil import subprocess import pytest @@ -24,19 +26,50 @@ NASTY = { "MULTILINE": "first\nsecond", } -# Prints each value NUL-terminated, in the order of NASTY. +# Prints each value NUL-terminated, in the order of NASTY; `$NAME` reads the same in both shells. PROBE = "printf '%s\\0' " + " ".join(f'"${name}"' for name in NASTY) +def _shell(dialect: str) -> list[str]: + if dialect == "sh": + return [bash(), "-c"] + found = shutil.which("fish") + if found is None: + pytest.skip("fish is not installed") + return [found, "--no-config", "-c"] + + @pytest.mark.platforms("posix") -def test_a_real_shell_reads_every_value_back_unchanged(): - script = shell_exports(NASTY) + "\n" + PROBE - run = subprocess.run([bash(), "-c", script], capture_output=True, text=True, timeout=30) +@pytest.mark.parametrize("dialect", ["sh", "fish"]) +def test_a_real_shell_reads_every_value_back_unchanged(dialect): + script = shell_exports(NASTY, dialect) + "\n" + PROBE + run = subprocess.run([*_shell(dialect), script], capture_output=True, text=True, timeout=30) assert run.returncode == 0, run.stderr assert run.stdout.split("\0")[:-1] == list(NASTY.values()) -def test_names_no_shell_can_assign_are_left_out(): - script = shell_exports({"ProgramFiles(ARM)": "x", "1BAD": "x", "GOOD": "y"}) +@pytest.mark.platforms("posix") +def test_fish_keeps_path_variables_as_lists(): + script = shell_exports({"PATH": "/a b:/c", "PYTHONPATH": "/x:/y"}, "fish") + run = subprocess.run( + [*_shell("fish"), script + "\nprintf '%s ' (count $PATH) (count $PYTHONPATH)"], + capture_output=True, text=True, timeout=30, + ) + assert run.returncode == 0, run.stderr + assert run.stdout.split() == ["2", "2"] + + +@pytest.mark.platforms("posix") +def test_fish_read_only_variables_do_not_abort_the_script(): + script = shell_exports({"PWD": "/elsewhere", "SHLVL": "9", "AFTER": "reached"}, "fish") + run = subprocess.run([*_shell("fish"), script + "\nprintf %s $AFTER"], + capture_output=True, text=True, timeout=30) + assert run.returncode == 0 and run.stderr == "", run.stderr + assert run.stdout == "reached" + + +@pytest.mark.parametrize("dialect", ["sh", "fish"]) +def test_names_no_shell_can_assign_are_left_out(dialect): + script = shell_exports({"ProgramFiles(ARM)": "x", "1BAD": "x", "GOOD": "y"}, dialect) assert "GOOD" in script assert "ProgramFiles" not in script and "1BAD" not in script diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index f93d91a831..8d245291e9 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -315,12 +315,14 @@ Then activate it; there is no separate setup command to remember: | Shell | Enter | Leave | |---|---|---| | Bash | `source ./activate` | `deactivate` | +| fish | `source ./activate.fish` | `deactivate` | | PowerShell | `. .\activate.ps1` | `deactivate` | The leading dot and space in PowerShell are required. Executing `.\activate.ps1` without dot-sourcing does not provide the same session scope. -The POSIX script uses Bash syntax. Use Bash for this recipe rather than `sh`, -fish, or assuming that a Zsh startup file has Bash semantics. +`activate` uses Bash syntax. Use Bash for this recipe rather than `sh`, or +assuming that a Zsh startup file has Bash semantics; fish has its own +`activate.fish`, which behaves the same. Each activation invokes PM's install/sync path and trusts the recorded tool digest instead of re-hashing every entry. PM still installs a missing tool and