From 9d6fd0704e3c5914c4c6c72faefcfc86e864adb1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 17 Jan 2026 13:54:48 +0100 Subject: [PATCH] Remove the use of `sh` in tests This commit has the following benefits: - Remove `sh` as a development dependency. - Increase test coverage on Windows. - Improve the robustness of some tests against leftover `.env` files in the repository. - This is not perfect yet: If you have a `.env` file in your repository, it still disrupts some tests (for `find_dotenv`). - Improve the readability of error messages for some tests. --- requirements.txt | 1 - tests/test_cli.py | 127 ++++++++++++++++---------------------- tests/test_lib.py | 46 ++++++++++++++ tests/test_main.py | 14 +++-- tests/test_zip_imports.py | 47 +++++++------- tox.ini | 1 - 6 files changed, 132 insertions(+), 104 deletions(-) create mode 100644 tests/test_lib.py diff --git a/requirements.txt b/requirements.txt index d3d0199f..4a9f28a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ click ipython pytest-cov pytest>=3.9 -sh>=2 tox wheel ruff diff --git a/tests/test_cli.py b/tests/test_cli.py index ebc4fdd9..02bdb764 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,32 +1,13 @@ import os -import subprocess -import sys from pathlib import Path -from typing import Optional, Sequence +from typing import Optional import pytest import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ - -if sys.platform != "win32": - import sh - - -def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: - """ - Invoke the `dotenv` CLI in a subprocess. - - This is necessary to test subcommands like `dotenv run` that replace the - current process. - """ - - return subprocess.run( - ["dotenv", *args], - capture_output=True, - text=True, - ) +from tests.test_lib import check_process, run_dotenv @pytest.mark.parametrize( @@ -192,111 +173,109 @@ def test_set_no_file(cli): assert "Missing argument" in result.output -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_get_default_path(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("get", "a") + result = run_dotenv(["get", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "y"}) - result = sh.dotenv("run", "printenv", "a", _env=env) + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path, env=env) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable_not_overridden(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "C"}) - result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) + result = run_dotenv( + ["run", "--no-override", "printenv", "A"], cwd=tmp_path, env=env + ) - assert result == "c\n" + check_process(result, exit_code=0, stdout="C\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_none_value(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b\nc") + (tmp_path / ".env").write_text("A=x\nc") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") -def test_run_with_other_env(dotenv_path): - dotenv_path.write_text("a=b") +def test_run_with_other_env(dotenv_path, tmp_path): + dotenv_path.write_text("A=x") - result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a") + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "A"], + cwd=tmp_path, + ) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ["run"]) +def test_run_without_cmd(tmp_path): + result = run_dotenv(["run"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"]) +def test_run_with_invalid_cmd(tmp_path): + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ["--version"]) +def test_run_with_version(tmp_path): + result = run_dotenv(["--version"], cwd=tmp_path) - assert result.exit_code == 0 - assert result.output.strip().endswith(__version__) + check_process(result, exit_code=0) + assert result.stdout.strip().endswith(__version__) -def test_run_with_command_flags(dotenv_path): +def test_run_with_command_flags(dotenv_path, tmp_path): """ Check that command flags passed after `dotenv run` are not interpreted. Here, we want to run `printenv --version`, not `dotenv --version`. """ - result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"]) + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, + ) - assert result.returncode == 0 + check_process(result, exit_code=0) assert result.stdout.strip().startswith("printenv ") -def test_run_with_dotenv_and_command_flags(cli, dotenv_path): +def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): """ Check that dotenv flags supersede command flags. """ - result = invoke_sub( - ["--version", "--file", dotenv_path, "run", "printenv", "--version"] + result = run_dotenv( + ["--version", "--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, ) - assert result.returncode == 0 + check_process(result, exit_code=0) assert result.stdout.strip().startswith("dotenv, version") diff --git a/tests/test_lib.py b/tests/test_lib.py new file mode 100644 index 00000000..eb9d5204 --- /dev/null +++ b/tests/test_lib.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path +from typing import Sequence + + +def run_dotenv( + args: Sequence[str], + cwd: str | Path | None = None, + env: dict | None = None, +) -> subprocess.CompletedProcess: + """ + Run the `dotenv` CLI in a subprocess with the given arguments. + """ + + process = subprocess.run( + ["dotenv", *args], + capture_output=True, + text=True, + cwd=cwd, + env=env, + ) + + return process + + +def check_process( + process: subprocess.CompletedProcess, + exit_code: int, + stdout: str | None = None, +): + """ + Check that the process completed with the expected exit code and output. + + This provides better error messages than directly checking the attributes. + """ + + assert process.returncode == exit_code, ( + f"Unexpected exit code {process.returncode} (expected {exit_code})\n" + f"stdout:\n{process.stdout}\n" + f"stderr:\n{process.stderr}" + ) + + if stdout is not None: + assert process.stdout == stdout, ( + f"Unexpected output: {process.stdout.strip()!r} (expected {stdout!r})" + ) diff --git a/tests/test_main.py b/tests/test_main.py index 761bdad3..616c3b0c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,7 @@ import logging import os import stat +import subprocess import sys import textwrap from unittest import mock @@ -10,9 +11,6 @@ import dotenv -if sys.platform != "win32": - import sh - def test_set_key_no_file(tmp_path): nx_path = tmp_path / "nx" @@ -483,7 +481,6 @@ def test_load_dotenv_file_stream(dotenv_path): assert os.environ == {"a": "b"} -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_bytes(b"a=b") @@ -499,9 +496,14 @@ def test_load_dotenv_in_current_dir(tmp_path): ) os.chdir(tmp_path) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + text=True, + check=True, + ) - assert result == "b\n" + assert result.stdout == "b\n" def test_dotenv_values_file(dotenv_path): diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 0b57a1c5..6a263502 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,22 +1,19 @@ import os +import posixpath +import subprocess import sys import textwrap from typing import List from unittest import mock from zipfile import ZipFile -import pytest - -if sys.platform != "win32": - import sh - def walk_to_root(path: str): last_dir = None current_dir = path while last_dir != current_dir: yield current_dir - (parent_dir, _) = os.path.split(current_dir) + parent_dir = posixpath.dirname(current_dir) last_dir, current_dir = current_dir, parent_dir @@ -32,12 +29,11 @@ def setup_zipfile(path, files: List[FileToAdd]): with ZipFile(zip_file_path, "w") as zipfile: for f in files: zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) - for dirname in walk_to_root(os.path.dirname(f.path)): + for dirname in walk_to_root(posixpath.dirname(f.path)): if dirname not in dirs_init_py_added_to: - print(os.path.join(dirname, "__init__.py")) - zipfile.writestr( - data="", zinfo_or_arcname=os.path.join(dirname, "__init__.py") - ) + init_path = posixpath.join(dirname, "__init__.py") + print(f"setup_zipfile: {init_path}") + zipfile.writestr(data="", zinfo_or_arcname=init_path) dirs_init_py_added_to.add(dirname) return zip_file_path @@ -65,7 +61,6 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path, @@ -83,24 +78,32 @@ def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): ], ) dotenv_path = tmp_path / ".env" - dotenv_path.write_bytes(b"a=b") + dotenv_path.write_bytes(b"A=x") code_path = tmp_path / "code.py" code_path.write_text( textwrap.dedent( f""" - import os - import sys + import os + import sys - sys.path.append("{zip_file_path}") + sys.path.append({str(zip_file_path)!r}) - import child1.child2.test + import child1.child2.test - print(os.environ['a']) - """ + print(os.environ['A']) + """ ) ) - os.chdir(str(tmp_path)) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + check=True, + cwd=tmp_path, + text=True, + env={ + k: v for k, v in os.environ.items() if k.upper() != "A" + }, # env without 'A' + ) - assert result == "b\n" + assert result.stdout == "x\n" diff --git a/tox.ini b/tox.ini index 6a25a3d4..6d1f25f8 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,6 @@ python = deps = pytest pytest-cov - sh >= 2.0.2, <3 click py{310,311,312,313,314,314t,pypy3}: ipython commands = pytest --cov --cov-report=term-missing {posargs}