Skip to content
Merged
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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ pytest --envfile .env.local # ignore configured env_files, load only t
pytest --envfile +.env.override # load configured env_files first, then this file on top
```

To keep existing environment variables (including with `--envfile`), set `env_files_skip_if_set = true`:

```toml
[tool.pytest_env]
env_files = [".env", ".env.test"]
env_files_skip_if_set = true
```

```ini
[pytest]
env_files =
.env
.env.test
env_files_skip_if_set = true
```

### Control variable behavior

Variables set as plain values are assigned directly. For more control, use inline tables with the `transform`,
Expand All @@ -107,7 +123,8 @@ TEMP_VAR = { unset = true }
```

`transform` expands `{VAR}` placeholders using existing environment variables. `skip_if_set` leaves the variable
unchanged when it already exists. `unset` removes it entirely (different from setting to empty string).
unchanged when it already exists. For `.env` files, use `env_files_skip_if_set = true`. `unset` removes it entirely
(different from setting to empty string).

### Set different environments for test suites

Expand Down Expand Up @@ -253,7 +270,8 @@ When multiple sources define the same variable, precedence applies in this order
1. Inline variables in configuration files (TOML or INI format).
1. Variables from `.env` files loaded via `env_files`. When using `--envfile`, CLI files take precedence over
configuration-based `env_files`.
1. Variables already present in the environment (preserved when `skip_if_set = true` or `D:` flag is used).
1. Variables already present in the environment (preserved when `skip_if_set = true`, `D:` flag is used, or
`env_files_skip_if_set = true`).

When multiple configuration formats are present, TOML native format (`[pytest_env]` / `[tool.pytest_env]`) takes
precedence over INI format. Among TOML files, the first file with a `pytest_env` section wins, checked in order:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
build-backend = "hatchling.build"
requires = [
"hatch-vcs>=0.5",
"hatchling>=1.28",
"hatchling>=1.29",
]

[project]
Expand Down Expand Up @@ -38,7 +38,7 @@ dynamic = [
]
dependencies = [
"pytest>=9.0.2",
"python-dotenv>=1.2.1",
"python-dotenv>=1.2.2",
"tomli>=2.4; python_version<'3.11'",
]
optional-dependencies.testing = [
Expand Down
46 changes: 36 additions & 10 deletions src/pytest_env/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE"
parser.addini("env", type="linelist", help=help_msg, default=[])
parser.addini("env_files", type="linelist", help="a line separated list of .env files to load", default=[])
parser.addini(
"env_files_skip_if_set",
type="bool",
help="only set .env file variables when not already defined",
default=False,
)
parser.addoption(
"--envfile",
action="store",
Expand Down Expand Up @@ -65,10 +71,19 @@ def pytest_load_initial_conftests(
actions: list[tuple[str, str, str, str]] = []

env_files_list: list[str] = []
env_files_skip_if_set: bool | None = None
if toml_config := _find_toml_config(early_config):
env_files_list, _ = _load_toml_config(toml_config)
env_files_list, _, env_files_skip_if_set = _load_toml_config(toml_config)

if env_files_skip_if_set is None:
env_files_skip_if_set = bool(early_config.getini("env_files_skip_if_set"))

_apply_env_files(early_config, env_files_list, actions if verbose else None)
_apply_env_files(
early_config,
env_files_list,
actions if verbose else None,
skip_if_set=env_files_skip_if_set,
)
_apply_entries(early_config, actions if verbose else None)

if verbose and actions:
Expand All @@ -79,13 +94,20 @@ def _apply_env_files(
early_config: pytest.Config,
env_files_list: list[str],
actions: list[tuple[str, str, str, str]] | None,
*,
skip_if_set: bool = False,
) -> None:
preexisting = dict(os.environ) if skip_if_set else {}
for env_file in _load_env_files(early_config, env_files_list):
for key, value in dotenv_values(env_file).items():
if value is not None:
os.environ[key] = value
if actions is not None:
actions.append(("SET", key, value, str(env_file)))
if skip_if_set and key in preexisting:
if actions is not None:
actions.append(("SKIP", key, preexisting[key], str(env_file)))
else:
os.environ[key] = value
if actions is not None:
actions.append(("SET", key, value, str(env_file)))


def _apply_entries(
Expand Down Expand Up @@ -146,15 +168,15 @@ def _find_toml_config(early_config: pytest.Config) -> Path | None:
def _config_source(early_config: pytest.Config) -> str:
"""Describe the configuration source for verbose output."""
if toml_path := _find_toml_config(early_config):
_, entries = _load_toml_config(toml_path)
_, entries, _ = _load_toml_config(toml_path)
if entries:
return str(toml_path)
if early_config.inipath:
return str(early_config.inipath)
return "config" # pragma: no cover


def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]:
def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry], bool | None]:
"""Load env_files and entries from TOML config file."""
with config_path.open("rb") as file_handler:
config = tomllib.load(file_handler)
Expand All @@ -164,13 +186,15 @@ def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]:

pytest_env_config = config.get("pytest_env", {})
if not pytest_env_config:
return [], []
return [], [], None

raw_env_files = pytest_env_config.get("env_files")
env_files = [str(f) for f in raw_env_files] if isinstance(raw_env_files, list) else []
raw_skip = pytest_env_config.get("env_files_skip_if_set")
env_files_skip_if_set = raw_skip if isinstance(raw_skip, bool) else None

entries = list(_parse_toml_config(pytest_env_config))
return env_files, entries
return env_files, entries, env_files_skip_if_set


def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]:
Expand Down Expand Up @@ -199,7 +223,7 @@ def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Genera
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
"""Load env entries from config, preferring TOML over INI."""
if toml_config := _find_toml_config(early_config):
_, entries = _load_toml_config(toml_config)
_, entries, _ = _load_toml_config(toml_config)
if entries:
yield from entries
return
Expand All @@ -224,6 +248,8 @@ def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
for key, entry in config.items():
if key == "env_files" and isinstance(entry, list):
continue
if key == "env_files_skip_if_set" and isinstance(entry, bool):
continue
if isinstance(entry, dict):
unset = bool(entry.get("unset"))
value = str(entry.get("value", "")) if not unset else ""
Expand Down
49 changes: 49 additions & 0 deletions tests/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,30 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
"pyproject",
id="skip if set respects env file",
),
pytest.param(
{"MAGIC": "original"},
"MAGIC=from_file",
dedent("""\
[tool.pytest_env]
env_files = [".env"]
env_files_skip_if_set = true
"""),
{"MAGIC": "original"},
"pyproject",
id="env_files_skip_if_set pyproject",
),
pytest.param(
{"MAGIC": "original"},
"MAGIC=from_file",
dedent("""\
[pytest]
env_files = .env
env_files_skip_if_set = true
"""),
{"MAGIC": "original"},
"ini",
id="env_files_skip_if_set ini",
),
pytest.param(
{},
"=no_key\nVALID=yes",
Expand Down Expand Up @@ -623,6 +647,31 @@ def test_envfile_cli( # noqa: PLR0913, PLR0917
result.assert_outcomes(passed=1)


def test_envfile_cli_skip_if_set(pytester: pytest.Pytester) -> None:
(pytester.path / "test_cli_skip.py").symlink_to(Path(__file__).parent / "template.py")
(pytester.path / "cli.env").write_text("MAGIC=from_cli", encoding="utf-8")
(pytester.path / "pyproject.toml").write_text(
dedent("""\
[tool.pytest_env]
env_files_skip_if_set = true
"""),
encoding="utf-8",
)

expected_env = {"MAGIC": "original"}
new_env = {
"MAGIC": "original",
"_TEST_ENV": repr(expected_env),
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
"PYTEST_PLUGINS": "pytest_env.plugin",
}

with mock.patch.dict(os.environ, new_env, clear=True):
result = pytester.runpytest("--envfile", "cli.env")

result.assert_outcomes(passed=1)


@pytest.mark.parametrize(
"cli_arg",
[
Expand Down
25 changes: 25 additions & 0 deletions tests/test_verbose.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,31 @@ def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester) -> None:
result.stdout.fnmatch_lines(["*SET*FROM_FILE=value*(from*.env*"])


def test_verbose_shows_skip_from_env_file(pytester: pytest.Pytester) -> None:
(pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py")
(pytester.path / ".env").write_text("FROM_FILE=value", encoding="utf-8")
(pytester.path / "pyproject.toml").write_text(
dedent("""\
[tool.pytest_env]
env_files = [".env"]
env_files_skip_if_set = true
"""),
encoding="utf-8",
)

new_env = {
"FROM_FILE": "original",
"_TEST_ENV": repr({"FROM_FILE": "original"}),
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
"PYTEST_PLUGINS": "pytest_env.plugin",
}
with mock.patch.dict(os.environ, new_env, clear=True):
result = pytester.runpytest("--pytest-env-verbose")

result.assert_outcomes(passed=1)
result.stdout.fnmatch_lines(["*SKIP*FROM_FILE=original*(from*.env*"])


def test_verbose_shows_skip(pytester: pytest.Pytester) -> None:
(pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py")
(pytester.path / "pytest.ini").write_text("[pytest]\nenv = D:EXISTING=new", encoding="utf-8")
Expand Down
53 changes: 26 additions & 27 deletions tox.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
requires = [
"tox>=4.36.1",
"tox-uv>=1.29",
]
env_list = [
"3.14",
"3.13",
"3.12",
"3.11",
"3.10",
"fix",
"pkg_meta",
"type",
]
skip_missing_interpreters = true

[env_run_base]
description = "run the tests with pytest"
package = "wheel"
Expand All @@ -22,19 +38,10 @@ commands = [
[ "coverage", "html", "-d", "{envtmpdir}{/}htmlcov" ],
]

[env.dev]
description = "generate a DEV environment"
package = "editable"
extras = [ "testing" ]
commands = [
[ "uv", "pip", "tree" ],
[ "python", "-c", "import sys; print(sys.executable)" ],
]

[env.fix]
description = "run static analysis and style check using flake8"
skip_install = true
deps = [ "pre-commit-uv>=4.2" ]
deps = [ "pre-commit-uv>=4.2.1" ]
pass_env = [ "HOMEPATH", "PROGRAMDATA" ]
commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ]

Expand All @@ -44,7 +51,7 @@ skip_install = true
deps = [
"check-wheel-contents>=0.6.3",
"twine>=6.2",
"uv>=0.10.3",
"uv>=0.10.9",
]
commands = [
[ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ],
Expand All @@ -54,22 +61,14 @@ commands = [

[env.type]
description = "run type check on code base"
deps = [ "ty==0.0.17" ]
deps = [ "ty==0.0.22" ]
commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "." ] ]

[tox]
requires = [
"tox>=4.36.1",
"tox-uv>=1.29",
]
env_list = [
"fix",
"3.14",
"3.13",
"3.12",
"3.11",
"3.10",
"type",
"pkg_meta",
[env.dev]
description = "generate a DEV environment"
package = "editable"
extras = [ "testing" ]
commands = [
[ "uv", "pip", "tree" ],
[ "python", "-c", "import sys; print(sys.executable)" ],
]
skip_missing_interpreters = true