Skip to content
Open
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
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ click
ipython
pytest-cov
pytest>=3.9
sh>=2
tox
wheel
ruff
Expand Down
127 changes: 53 additions & 74 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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")
46 changes: 46 additions & 0 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
@@ -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})"
)
14 changes: 8 additions & 6 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
import stat
import subprocess
import sys
import textwrap
from unittest import mock
Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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):
Expand Down
47 changes: 25 additions & 22 deletions tests/test_zip_imports.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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"
1 change: 0 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down