Skip to content

Commit 43340da

Browse files
authored
Remove the use of sh in tests (#612)
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.
1 parent 09d7cee commit 43340da

File tree

6 files changed

+132
-104
lines changed

6 files changed

+132
-104
lines changed

requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ click
33
ipython
44
pytest-cov
55
pytest>=3.9
6-
sh>=2
76
tox
87
wheel
98
ruff

tests/test_cli.py

Lines changed: 53 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,13 @@
11
import os
2-
import subprocess
3-
import sys
42
from pathlib import Path
5-
from typing import Optional, Sequence
3+
from typing import Optional
64

75
import pytest
86

97
import dotenv
108
from dotenv.cli import cli as dotenv_cli
119
from dotenv.version import __version__
12-
13-
if sys.platform != "win32":
14-
import sh
15-
16-
17-
def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess:
18-
"""
19-
Invoke the `dotenv` CLI in a subprocess.
20-
21-
This is necessary to test subcommands like `dotenv run` that replace the
22-
current process.
23-
"""
24-
25-
return subprocess.run(
26-
["dotenv", *args],
27-
capture_output=True,
28-
text=True,
29-
)
10+
from tests.test_lib import check_process, run_dotenv
3011

3112

3213
@pytest.mark.parametrize(
@@ -192,111 +173,109 @@ def test_set_no_file(cli):
192173
assert "Missing argument" in result.output
193174

194175

195-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
196176
def test_get_default_path(tmp_path):
197-
with sh.pushd(tmp_path):
198-
(tmp_path / ".env").write_text("a=b")
177+
(tmp_path / ".env").write_text("A=x")
199178

200-
result = sh.dotenv("get", "a")
179+
result = run_dotenv(["get", "A"], cwd=tmp_path)
201180

202-
assert result == "b\n"
181+
check_process(result, exit_code=0, stdout="x\n")
203182

204183

205-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
206184
def test_run(tmp_path):
207-
with sh.pushd(tmp_path):
208-
(tmp_path / ".env").write_text("a=b")
185+
(tmp_path / ".env").write_text("A=x")
209186

210-
result = sh.dotenv("run", "printenv", "a")
187+
result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path)
211188

212-
assert result == "b\n"
189+
check_process(result, exit_code=0, stdout="x\n")
213190

214191

215-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
216192
def test_run_with_existing_variable(tmp_path):
217-
with sh.pushd(tmp_path):
218-
(tmp_path / ".env").write_text("a=b")
219-
env = dict(os.environ)
220-
env.update({"LANG": "en_US.UTF-8", "a": "c"})
193+
(tmp_path / ".env").write_text("A=x")
194+
env = dict(os.environ)
195+
env.update({"LANG": "en_US.UTF-8", "A": "y"})
221196

222-
result = sh.dotenv("run", "printenv", "a", _env=env)
197+
result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path, env=env)
223198

224-
assert result == "b\n"
199+
check_process(result, exit_code=0, stdout="x\n")
225200

226201

227-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
228202
def test_run_with_existing_variable_not_overridden(tmp_path):
229-
with sh.pushd(tmp_path):
230-
(tmp_path / ".env").write_text("a=b")
231-
env = dict(os.environ)
232-
env.update({"LANG": "en_US.UTF-8", "a": "c"})
203+
(tmp_path / ".env").write_text("A=x")
204+
env = dict(os.environ)
205+
env.update({"LANG": "en_US.UTF-8", "A": "C"})
233206

234-
result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env)
207+
result = run_dotenv(
208+
["run", "--no-override", "printenv", "A"], cwd=tmp_path, env=env
209+
)
235210

236-
assert result == "c\n"
211+
check_process(result, exit_code=0, stdout="C\n")
237212

238213

239-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
240214
def test_run_with_none_value(tmp_path):
241-
with sh.pushd(tmp_path):
242-
(tmp_path / ".env").write_text("a=b\nc")
215+
(tmp_path / ".env").write_text("A=x\nc")
243216

244-
result = sh.dotenv("run", "printenv", "a")
217+
result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path)
245218

246-
assert result == "b\n"
219+
check_process(result, exit_code=0, stdout="x\n")
247220

248221

249-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
250-
def test_run_with_other_env(dotenv_path):
251-
dotenv_path.write_text("a=b")
222+
def test_run_with_other_env(dotenv_path, tmp_path):
223+
dotenv_path.write_text("A=x")
252224

253-
result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a")
225+
result = run_dotenv(
226+
["--file", str(dotenv_path), "run", "printenv", "A"],
227+
cwd=tmp_path,
228+
)
254229

255-
assert result == "b\n"
230+
check_process(result, exit_code=0, stdout="x\n")
256231

257232

258-
def test_run_without_cmd(cli):
259-
result = cli.invoke(dotenv_cli, ["run"])
233+
def test_run_without_cmd(tmp_path):
234+
result = run_dotenv(["run"], cwd=tmp_path)
260235

261-
assert result.exit_code == 2
262-
assert "Invalid value for '-f'" in result.output
236+
check_process(result, exit_code=2)
237+
assert "Invalid value for '-f'" in result.stderr
263238

264239

265-
def test_run_with_invalid_cmd(cli):
266-
result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"])
240+
def test_run_with_invalid_cmd(tmp_path):
241+
result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path)
267242

268-
assert result.exit_code == 2
269-
assert "Invalid value for '-f'" in result.output
243+
check_process(result, exit_code=2)
244+
assert "Invalid value for '-f'" in result.stderr
270245

271246

272-
def test_run_with_version(cli):
273-
result = cli.invoke(dotenv_cli, ["--version"])
247+
def test_run_with_version(tmp_path):
248+
result = run_dotenv(["--version"], cwd=tmp_path)
274249

275-
assert result.exit_code == 0
276-
assert result.output.strip().endswith(__version__)
250+
check_process(result, exit_code=0)
251+
assert result.stdout.strip().endswith(__version__)
277252

278253

279-
def test_run_with_command_flags(dotenv_path):
254+
def test_run_with_command_flags(dotenv_path, tmp_path):
280255
"""
281256
Check that command flags passed after `dotenv run` are not interpreted.
282257
283258
Here, we want to run `printenv --version`, not `dotenv --version`.
284259
"""
285260

286-
result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"])
261+
result = run_dotenv(
262+
["--file", str(dotenv_path), "run", "printenv", "--version"],
263+
cwd=tmp_path,
264+
)
287265

288-
assert result.returncode == 0
266+
check_process(result, exit_code=0)
289267
assert result.stdout.strip().startswith("printenv ")
290268

291269

292-
def test_run_with_dotenv_and_command_flags(cli, dotenv_path):
270+
def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path):
293271
"""
294272
Check that dotenv flags supersede command flags.
295273
"""
296274

297-
result = invoke_sub(
298-
["--version", "--file", dotenv_path, "run", "printenv", "--version"]
275+
result = run_dotenv(
276+
["--version", "--file", str(dotenv_path), "run", "printenv", "--version"],
277+
cwd=tmp_path,
299278
)
300279

301-
assert result.returncode == 0
280+
check_process(result, exit_code=0)
302281
assert result.stdout.strip().startswith("dotenv, version")

tests/test_lib.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import subprocess
2+
from pathlib import Path
3+
from typing import Sequence
4+
5+
6+
def run_dotenv(
7+
args: Sequence[str],
8+
cwd: str | Path | None = None,
9+
env: dict | None = None,
10+
) -> subprocess.CompletedProcess:
11+
"""
12+
Run the `dotenv` CLI in a subprocess with the given arguments.
13+
"""
14+
15+
process = subprocess.run(
16+
["dotenv", *args],
17+
capture_output=True,
18+
text=True,
19+
cwd=cwd,
20+
env=env,
21+
)
22+
23+
return process
24+
25+
26+
def check_process(
27+
process: subprocess.CompletedProcess,
28+
exit_code: int,
29+
stdout: str | None = None,
30+
):
31+
"""
32+
Check that the process completed with the expected exit code and output.
33+
34+
This provides better error messages than directly checking the attributes.
35+
"""
36+
37+
assert process.returncode == exit_code, (
38+
f"Unexpected exit code {process.returncode} (expected {exit_code})\n"
39+
f"stdout:\n{process.stdout}\n"
40+
f"stderr:\n{process.stderr}"
41+
)
42+
43+
if stdout is not None:
44+
assert process.stdout == stdout, (
45+
f"Unexpected output: {process.stdout.strip()!r} (expected {stdout!r})"
46+
)

tests/test_main.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import stat
5+
import subprocess
56
import sys
67
import textwrap
78
from unittest import mock
@@ -10,9 +11,6 @@
1011

1112
import dotenv
1213

13-
if sys.platform != "win32":
14-
import sh
15-
1614

1715
def test_set_key_no_file(tmp_path):
1816
nx_path = tmp_path / "nx"
@@ -483,7 +481,6 @@ def test_load_dotenv_file_stream(dotenv_path):
483481
assert os.environ == {"a": "b"}
484482

485483

486-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
487484
def test_load_dotenv_in_current_dir(tmp_path):
488485
dotenv_path = tmp_path / ".env"
489486
dotenv_path.write_bytes(b"a=b")
@@ -499,9 +496,14 @@ def test_load_dotenv_in_current_dir(tmp_path):
499496
)
500497
os.chdir(tmp_path)
501498

502-
result = sh.Command(sys.executable)(code_path)
499+
result = subprocess.run(
500+
[sys.executable, str(code_path)],
501+
capture_output=True,
502+
text=True,
503+
check=True,
504+
)
503505

504-
assert result == "b\n"
506+
assert result.stdout == "b\n"
505507

506508

507509
def test_dotenv_values_file(dotenv_path):

tests/test_zip_imports.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
import os
2+
import posixpath
3+
import subprocess
24
import sys
35
import textwrap
46
from typing import List
57
from unittest import mock
68
from zipfile import ZipFile
79

8-
import pytest
9-
10-
if sys.platform != "win32":
11-
import sh
12-
1310

1411
def walk_to_root(path: str):
1512
last_dir = None
1613
current_dir = path
1714
while last_dir != current_dir:
1815
yield current_dir
19-
(parent_dir, _) = os.path.split(current_dir)
16+
parent_dir = posixpath.dirname(current_dir)
2017
last_dir, current_dir = current_dir, parent_dir
2118

2219

@@ -32,12 +29,11 @@ def setup_zipfile(path, files: List[FileToAdd]):
3229
with ZipFile(zip_file_path, "w") as zipfile:
3330
for f in files:
3431
zipfile.writestr(data=f.content, zinfo_or_arcname=f.path)
35-
for dirname in walk_to_root(os.path.dirname(f.path)):
32+
for dirname in walk_to_root(posixpath.dirname(f.path)):
3633
if dirname not in dirs_init_py_added_to:
37-
print(os.path.join(dirname, "__init__.py"))
38-
zipfile.writestr(
39-
data="", zinfo_or_arcname=os.path.join(dirname, "__init__.py")
40-
)
34+
init_path = posixpath.join(dirname, "__init__.py")
35+
print(f"setup_zipfile: {init_path}")
36+
zipfile.writestr(data="", zinfo_or_arcname=init_path)
4137
dirs_init_py_added_to.add(dirname)
4238
return zip_file_path
4339

@@ -65,7 +61,6 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path):
6561
import child1.child2.test # noqa
6662

6763

68-
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
6964
def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path):
7065
zip_file_path = setup_zipfile(
7166
tmp_path,
@@ -83,24 +78,32 @@ def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path):
8378
],
8479
)
8580
dotenv_path = tmp_path / ".env"
86-
dotenv_path.write_bytes(b"a=b")
81+
dotenv_path.write_bytes(b"A=x")
8782
code_path = tmp_path / "code.py"
8883
code_path.write_text(
8984
textwrap.dedent(
9085
f"""
91-
import os
92-
import sys
86+
import os
87+
import sys
9388
94-
sys.path.append("{zip_file_path}")
89+
sys.path.append({str(zip_file_path)!r})
9590
96-
import child1.child2.test
91+
import child1.child2.test
9792
98-
print(os.environ['a'])
99-
"""
93+
print(os.environ['A'])
94+
"""
10095
)
10196
)
102-
os.chdir(str(tmp_path))
10397

104-
result = sh.Command(sys.executable)(code_path)
98+
result = subprocess.run(
99+
[sys.executable, str(code_path)],
100+
capture_output=True,
101+
check=True,
102+
cwd=tmp_path,
103+
text=True,
104+
env={
105+
k: v for k, v in os.environ.items() if k.upper() != "A"
106+
}, # env without 'A'
107+
)
105108

106-
assert result == "b\n"
109+
assert result.stdout == "x\n"

tox.ini

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ python =
1515
deps =
1616
pytest
1717
pytest-cov
18-
sh >= 2.0.2, <3
1918
click
2019
py{310,311,312,313,314,314t,pypy3}: ipython
2120
commands = pytest --cov --cov-report=term-missing {posargs}

0 commit comments

Comments
 (0)