Skip to content

Commit 8bd22d1

Browse files
authored
✨ feat(env): preserve existing env values (#213)
1 parent 81f9e0e commit 8bd22d1

File tree

6 files changed

+158
-41
lines changed

6 files changed

+158
-41
lines changed

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ pytest --envfile .env.local # ignore configured env_files, load only t
9393
pytest --envfile +.env.override # load configured env_files first, then this file on top
9494
```
9595

96+
To keep existing environment variables (including with `--envfile`), set `env_files_skip_if_set = true`:
97+
98+
```toml
99+
[tool.pytest_env]
100+
env_files = [".env", ".env.test"]
101+
env_files_skip_if_set = true
102+
```
103+
104+
```ini
105+
[pytest]
106+
env_files =
107+
.env
108+
.env.test
109+
env_files_skip_if_set = true
110+
```
111+
96112
### Control variable behavior
97113

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

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

112129
### Set different environments for test suites
113130

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

258276
When multiple configuration formats are present, TOML native format (`[pytest_env]` / `[tool.pytest_env]`) takes
259277
precedence over INI format. Among TOML files, the first file with a `pytest_env` section wins, checked in order:

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
build-backend = "hatchling.build"
33
requires = [
44
"hatch-vcs>=0.5",
5-
"hatchling>=1.28",
5+
"hatchling>=1.29",
66
]
77

88
[project]
@@ -38,7 +38,7 @@ dynamic = [
3838
]
3939
dependencies = [
4040
"pytest>=9.0.2",
41-
"python-dotenv>=1.2.1",
41+
"python-dotenv>=1.2.2",
4242
"tomli>=2.4; python_version<'3.11'",
4343
]
4444
optional-dependencies.testing = [

src/pytest_env/plugin.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
2727
help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE"
2828
parser.addini("env", type="linelist", help=help_msg, default=[])
2929
parser.addini("env_files", type="linelist", help="a line separated list of .env files to load", default=[])
30+
parser.addini(
31+
"env_files_skip_if_set",
32+
type="bool",
33+
help="only set .env file variables when not already defined",
34+
default=False,
35+
)
3036
parser.addoption(
3137
"--envfile",
3238
action="store",
@@ -65,10 +71,19 @@ def pytest_load_initial_conftests(
6571
actions: list[tuple[str, str, str, str]] = []
6672

6773
env_files_list: list[str] = []
74+
env_files_skip_if_set: bool | None = None
6875
if toml_config := _find_toml_config(early_config):
69-
env_files_list, _ = _load_toml_config(toml_config)
76+
env_files_list, _, env_files_skip_if_set = _load_toml_config(toml_config)
77+
78+
if env_files_skip_if_set is None:
79+
env_files_skip_if_set = bool(early_config.getini("env_files_skip_if_set"))
7080

71-
_apply_env_files(early_config, env_files_list, actions if verbose else None)
81+
_apply_env_files(
82+
early_config,
83+
env_files_list,
84+
actions if verbose else None,
85+
skip_if_set=env_files_skip_if_set,
86+
)
7287
_apply_entries(early_config, actions if verbose else None)
7388

7489
if verbose and actions:
@@ -79,13 +94,20 @@ def _apply_env_files(
7994
early_config: pytest.Config,
8095
env_files_list: list[str],
8196
actions: list[tuple[str, str, str, str]] | None,
97+
*,
98+
skip_if_set: bool = False,
8299
) -> None:
100+
preexisting = dict(os.environ) if skip_if_set else {}
83101
for env_file in _load_env_files(early_config, env_files_list):
84102
for key, value in dotenv_values(env_file).items():
85103
if value is not None:
86-
os.environ[key] = value
87-
if actions is not None:
88-
actions.append(("SET", key, value, str(env_file)))
104+
if skip_if_set and key in preexisting:
105+
if actions is not None:
106+
actions.append(("SKIP", key, preexisting[key], str(env_file)))
107+
else:
108+
os.environ[key] = value
109+
if actions is not None:
110+
actions.append(("SET", key, value, str(env_file)))
89111

90112

91113
def _apply_entries(
@@ -146,15 +168,15 @@ def _find_toml_config(early_config: pytest.Config) -> Path | None:
146168
def _config_source(early_config: pytest.Config) -> str:
147169
"""Describe the configuration source for verbose output."""
148170
if toml_path := _find_toml_config(early_config):
149-
_, entries = _load_toml_config(toml_path)
171+
_, entries, _ = _load_toml_config(toml_path)
150172
if entries:
151173
return str(toml_path)
152174
if early_config.inipath:
153175
return str(early_config.inipath)
154176
return "config" # pragma: no cover
155177

156178

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

165187
pytest_env_config = config.get("pytest_env", {})
166188
if not pytest_env_config:
167-
return [], []
189+
return [], [], None
168190

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

172196
entries = list(_parse_toml_config(pytest_env_config))
173-
return env_files, entries
197+
return env_files, entries, env_files_skip_if_set
174198

175199

176200
def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]:
@@ -199,7 +223,7 @@ def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Genera
199223
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
200224
"""Load env entries from config, preferring TOML over INI."""
201225
if toml_config := _find_toml_config(early_config):
202-
_, entries = _load_toml_config(toml_config)
226+
_, entries, _ = _load_toml_config(toml_config)
203227
if entries:
204228
yield from entries
205229
return
@@ -224,6 +248,8 @@ def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
224248
for key, entry in config.items():
225249
if key == "env_files" and isinstance(entry, list):
226250
continue
251+
if key == "env_files_skip_if_set" and isinstance(entry, bool):
252+
continue
227253
if isinstance(entry, dict):
228254
unset = bool(entry.get("unset"))
229255
value = str(entry.get("value", "")) if not unset else ""

tests/test_env.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,30 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
408408
"pyproject",
409409
id="skip if set respects env file",
410410
),
411+
pytest.param(
412+
{"MAGIC": "original"},
413+
"MAGIC=from_file",
414+
dedent("""\
415+
[tool.pytest_env]
416+
env_files = [".env"]
417+
env_files_skip_if_set = true
418+
"""),
419+
{"MAGIC": "original"},
420+
"pyproject",
421+
id="env_files_skip_if_set pyproject",
422+
),
423+
pytest.param(
424+
{"MAGIC": "original"},
425+
"MAGIC=from_file",
426+
dedent("""\
427+
[pytest]
428+
env_files = .env
429+
env_files_skip_if_set = true
430+
"""),
431+
{"MAGIC": "original"},
432+
"ini",
433+
id="env_files_skip_if_set ini",
434+
),
411435
pytest.param(
412436
{},
413437
"=no_key\nVALID=yes",
@@ -623,6 +647,31 @@ def test_envfile_cli( # noqa: PLR0913, PLR0917
623647
result.assert_outcomes(passed=1)
624648

625649

650+
def test_envfile_cli_skip_if_set(pytester: pytest.Pytester) -> None:
651+
(pytester.path / "test_cli_skip.py").symlink_to(Path(__file__).parent / "template.py")
652+
(pytester.path / "cli.env").write_text("MAGIC=from_cli", encoding="utf-8")
653+
(pytester.path / "pyproject.toml").write_text(
654+
dedent("""\
655+
[tool.pytest_env]
656+
env_files_skip_if_set = true
657+
"""),
658+
encoding="utf-8",
659+
)
660+
661+
expected_env = {"MAGIC": "original"}
662+
new_env = {
663+
"MAGIC": "original",
664+
"_TEST_ENV": repr(expected_env),
665+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
666+
"PYTEST_PLUGINS": "pytest_env.plugin",
667+
}
668+
669+
with mock.patch.dict(os.environ, new_env, clear=True):
670+
result = pytester.runpytest("--envfile", "cli.env")
671+
672+
result.assert_outcomes(passed=1)
673+
674+
626675
@pytest.mark.parametrize(
627676
"cli_arg",
628677
[

tests/test_verbose.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,31 @@ def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester) -> None:
8989
result.stdout.fnmatch_lines(["*SET*FROM_FILE=value*(from*.env*"])
9090

9191

92+
def test_verbose_shows_skip_from_env_file(pytester: pytest.Pytester) -> None:
93+
(pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py")
94+
(pytester.path / ".env").write_text("FROM_FILE=value", encoding="utf-8")
95+
(pytester.path / "pyproject.toml").write_text(
96+
dedent("""\
97+
[tool.pytest_env]
98+
env_files = [".env"]
99+
env_files_skip_if_set = true
100+
"""),
101+
encoding="utf-8",
102+
)
103+
104+
new_env = {
105+
"FROM_FILE": "original",
106+
"_TEST_ENV": repr({"FROM_FILE": "original"}),
107+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
108+
"PYTEST_PLUGINS": "pytest_env.plugin",
109+
}
110+
with mock.patch.dict(os.environ, new_env, clear=True):
111+
result = pytester.runpytest("--pytest-env-verbose")
112+
113+
result.assert_outcomes(passed=1)
114+
result.stdout.fnmatch_lines(["*SKIP*FROM_FILE=original*(from*.env*"])
115+
116+
92117
def test_verbose_shows_skip(pytester: pytest.Pytester) -> None:
93118
(pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py")
94119
(pytester.path / "pytest.ini").write_text("[pytest]\nenv = D:EXISTING=new", encoding="utf-8")

tox.toml

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
requires = [
2+
"tox>=4.36.1",
3+
"tox-uv>=1.29",
4+
]
5+
env_list = [
6+
"3.14",
7+
"3.13",
8+
"3.12",
9+
"3.11",
10+
"3.10",
11+
"fix",
12+
"pkg_meta",
13+
"type",
14+
]
15+
skip_missing_interpreters = true
16+
117
[env_run_base]
218
description = "run the tests with pytest"
319
package = "wheel"
@@ -22,19 +38,10 @@ commands = [
2238
[ "coverage", "html", "-d", "{envtmpdir}{/}htmlcov" ],
2339
]
2440

25-
[env.dev]
26-
description = "generate a DEV environment"
27-
package = "editable"
28-
extras = [ "testing" ]
29-
commands = [
30-
[ "uv", "pip", "tree" ],
31-
[ "python", "-c", "import sys; print(sys.executable)" ],
32-
]
33-
3441
[env.fix]
3542
description = "run static analysis and style check using flake8"
3643
skip_install = true
37-
deps = [ "pre-commit-uv>=4.2" ]
44+
deps = [ "pre-commit-uv>=4.2.1" ]
3845
pass_env = [ "HOMEPATH", "PROGRAMDATA" ]
3946
commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ]
4047

@@ -44,7 +51,7 @@ skip_install = true
4451
deps = [
4552
"check-wheel-contents>=0.6.3",
4653
"twine>=6.2",
47-
"uv>=0.10.3",
54+
"uv>=0.10.9",
4855
]
4956
commands = [
5057
[ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ],
@@ -54,22 +61,14 @@ commands = [
5461

5562
[env.type]
5663
description = "run type check on code base"
57-
deps = [ "ty==0.0.17" ]
64+
deps = [ "ty==0.0.22" ]
5865
commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "." ] ]
5966

60-
[tox]
61-
requires = [
62-
"tox>=4.36.1",
63-
"tox-uv>=1.29",
64-
]
65-
env_list = [
66-
"fix",
67-
"3.14",
68-
"3.13",
69-
"3.12",
70-
"3.11",
71-
"3.10",
72-
"type",
73-
"pkg_meta",
67+
[env.dev]
68+
description = "generate a DEV environment"
69+
package = "editable"
70+
extras = [ "testing" ]
71+
commands = [
72+
[ "uv", "pip", "tree" ],
73+
[ "python", "-c", "import sys; print(sys.executable)" ],
7474
]
75-
skip_missing_interpreters = true

0 commit comments

Comments
 (0)