# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import logging
import os
import stat
import time
import pytest
from extra_platforms import is_any_windows
from meta_package_manager import shell_env
from meta_package_manager.shell_env import (
SHELL_ENV_GUARD,
ShellEnvError,
import_shell_env,
read_shell_env,
shell_argv,
)
pytestmark = [
pytest.mark.skipif(
is_any_windows(),
reason="The import is a no-op on Windows, and the stand-in shells are "
"POSIX scripts.",
),
# Every test here spawns the user's login shell and reads what it exports.
# A hermetic build sandbox gives its build user neither a shell nor a
# passwd entry, so the module is host-dependent throughout.
pytest.mark.host_environment,
]
[docs]
def fake_shell(tmp_path, body: str = "") -> str:
"""A stand-in login shell: runs `body`, drops `-i -l` and hands `-c` to `/bin/sh`.
A real login shell would read the developer's own profile and change `PATH`
in ways no assertion can predict. `body` is where a test exports what its
shell answers with.
"""
script = tmp_path / "fakesh"
script.write_text(
f'#!/bin/sh\n{body}\nshift 2\nexec /bin/sh "$@"\n', encoding="UTF-8"
)
script.chmod(script.stat().st_mode | stat.S_IXUSR)
return str(script)
[docs]
def bare_environ(tmp_path, shell: str | None) -> dict[str, str]:
"""The environment a desktop session hands a process: a bare `PATH`."""
environ = {"HOME": str(tmp_path), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}
if shell:
environ["SHELL"] = shell
return environ
[docs]
@pytest.mark.parametrize(
("shell", "flags"),
[
pytest.param("/bin/zsh", ("-i", "-l", "-c"), id="zsh"),
pytest.param("/opt/homebrew/bin/fish", ("-i", "-l", "-c"), id="fish"),
pytest.param("/usr/local/bin/nu", ("-i", "-l", "-c"), id="nushell"),
pytest.param("/bin/csh", ("-ic",), id="csh"),
pytest.param("/bin/tcsh", ("-ic",), id="tcsh"),
pytest.param("/usr/local/bin/pwsh", ("-Login", "-Command"), id="pwsh"),
],
)
def test_shell_argv(shell, flags):
argv = shell_argv(shell, "MARK")
assert argv[0] == shell
assert argv[1:-1] == flags
assert argv[-1].count("MARK") == 2
assert "/usr/bin/env -0" in argv[-1]
[docs]
def test_read_shell_env_returns_the_exports(tmp_path):
shell = fake_shell(
tmp_path,
'export FRUIT=apple\nexport PATH="/orchard/bin:$PATH"\n'
'export RECIPE="two\nlines"\necho greeting from the rc file',
)
answer = read_shell_env(shell, bare_environ(tmp_path, shell))
assert answer["FRUIT"] == "apple"
assert answer["PATH"] == "/orchard/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# `env -0` keeps a value holding a newline in one piece.
assert answer["RECIPE"] == "two\nlines"
# The child sees the guard.
assert answer[SHELL_ENV_GUARD] == "1"
[docs]
@pytest.mark.parametrize(
("script", "error"),
(
pytest.param("exit 3", "exited with code 3", id="failing"),
pytest.param("exit 0", "printed no environment", id="silent"),
# No script: the shell path names no file at all.
pytest.param(None, "cannot start", id="missing"),
),
)
def test_read_shell_env_rejects_a_broken_shell(tmp_path, script, error):
if script is None:
shell = str(tmp_path / "nonexistent-shell")
else:
shell = fake_shell(tmp_path, script)
with pytest.raises(ShellEnvError, match=error):
read_shell_env(shell, bare_environ(tmp_path, shell))
[docs]
def test_read_shell_env_times_out(tmp_path):
shell = fake_shell(tmp_path, "sleep 30")
start = time.monotonic()
with pytest.raises(ShellEnvError, match=r"did not answer within 0\.5 seconds"):
read_shell_env(shell, bare_environ(tmp_path, shell), timeout=0.5)
# The shell and its process group were killed rather than waited for.
assert time.monotonic() - start < 10
[docs]
def test_import_shell_env_adopts_the_answer(tmp_path, caplog):
caplog.set_level(logging.DEBUG)
shell = fake_shell(
tmp_path,
'export FRUIT=apple\nexport PATH="/orchard/bin:$PATH"\n'
"export SHLVL=42\nexport OLDPWD=/elsewhere",
)
environ = bare_environ(tmp_path, shell)
changes = import_shell_env(environ)
assert changes is not None
assert changes["FRUIT"] == "apple"
assert environ["FRUIT"] == "apple"
assert environ["PATH"] == "/orchard/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# The shell's own bookkeeping, and the guard, stay out.
for name in ("PWD", "OLDPWD", "SHLVL", "_", SHELL_ENV_GUARD):
assert name not in changes
assert name not in environ
assert f"Imported {len(changes)} variables from the login shell {shell}" in (
caplog.text
)
assert "PATH gained /orchard/bin." in caplog.text
assert "FRUIT" in caplog.text
[docs]
def test_import_shell_env_reports_an_unchanged_path(tmp_path, caplog):
caplog.set_level(logging.INFO)
shell = fake_shell(tmp_path, "export FRUIT=apple")
environ = bare_environ(tmp_path, shell)
assert import_shell_env(environ) == {"FRUIT": "apple"}
assert "PATH is unchanged." in caplog.text
[docs]
def test_import_shell_env_skips_inside_a_probe(tmp_path):
"""The guard stops `mpm --shell-env` from spawning a shell in a shell.
When a shell configuration runs `mpm --shell-env`.
"""
sentinel = tmp_path / "spawned"
shell = fake_shell(tmp_path, f"touch {sentinel}")
environ = bare_environ(tmp_path, shell)
environ[SHELL_ENV_GUARD] = "1"
assert import_shell_env(environ) is None
assert not sentinel.exists()
assert "PATH" in environ
[docs]
def test_import_shell_env_warns_on_a_failing_shell(tmp_path, caplog):
caplog.set_level(logging.WARNING)
shell = fake_shell(tmp_path, "exit 3")
environ = bare_environ(tmp_path, shell)
assert import_shell_env(environ) is None
assert environ == bare_environ(tmp_path, shell)
assert (
f"Could not import the login shell environment: {shell} exited with code 3."
) in caplog.text
[docs]
def test_import_shell_env_needs_a_shell(tmp_path, monkeypatch, caplog):
caplog.set_level(logging.WARNING)
monkeypatch.setattr(shell_env, "_login_shell", lambda: None)
environ = bare_environ(tmp_path, None)
assert import_shell_env(environ) is None
assert "no shell is known for this user" in caplog.text
[docs]
def test_import_shell_env_falls_back_to_the_passwd_shell(tmp_path, monkeypatch):
shell = fake_shell(tmp_path, "export FRUIT=apple")
monkeypatch.setattr(shell_env, "_login_shell", lambda: shell)
environ = bare_environ(tmp_path, None)
assert import_shell_env(environ) == {"FRUIT": "apple"}
[docs]
def test_import_shell_env_is_ignored_on_windows(tmp_path, monkeypatch, caplog):
caplog.set_level(logging.INFO)
monkeypatch.setattr(shell_env, "is_any_windows", lambda: True)
sentinel = tmp_path / "spawned"
shell = fake_shell(tmp_path, f"touch {sentinel}")
assert import_shell_env(bare_environ(tmp_path, shell)) is None
assert not sentinel.exists()
assert "Ignoring --shell-env" in caplog.text
[docs]
def test_cli_shell_env_option(invoke, fake_pool, monkeypatch, tmp_path):
"""`--shell-env` adopts the answer of the shell before manager selection.
It goes into the process environment, and is narrated at `INFO`.
"""
shell = fake_shell(tmp_path, "export MPM_TEST_FRUIT=apple")
monkeypatch.setenv("SHELL", shell)
# Registered with monkeypatch first, so its teardown restores the variable
# the import below writes straight into the process environment.
monkeypatch.setenv("MPM_TEST_FRUIT", "pear")
monkeypatch.setenv("PATH", os.environ["PATH"])
result = invoke("--shell-env", "--verbosity", "INFO", "managers")
assert result.exit_code == 0
assert os.environ["MPM_TEST_FRUIT"] == "apple"
assert f"Imported 1 variables from the login shell {shell}" in result.stderr
[docs]
def test_cli_shell_env_is_off_by_default(invoke, fake_pool, monkeypatch, tmp_path):
sentinel = tmp_path / "spawned"
monkeypatch.setenv("SHELL", fake_shell(tmp_path, f"touch {sentinel}"))
result = invoke("--verbosity", "INFO", "managers")
assert result.exit_code == 0
assert not sentinel.exists()
assert "login shell" not in result.stderr