Skip to content

Commit cd5d78c

Browse files
authored
✨ feat(plugin): add .env file loading support (#191)
1 parent 9e52830 commit cd5d78c

File tree

6 files changed

+267
-13
lines changed

6 files changed

+267
-13
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ repos:
1313
rev: v2.4.1
1414
hooks:
1515
- id: codespell
16-
additional_dependencies: ["tomli>=2.2.1"]
16+
additional_dependencies: ["tomli>=2.4"]
1717
- repo: https://github.com/tox-dev/tox-ini-fmt
1818
rev: "1.7.1"
1919
hooks:
2020
- id: tox-ini-fmt
2121
args: ["-p", "fix"]
2222
- repo: https://github.com/tox-dev/pyproject-fmt
23-
rev: "v2.15.0"
23+
rev: "v2.15.2"
2424
hooks:
2525
- id: pyproject-fmt
2626
- repo: https://github.com/astral-sh/ruff-pre-commit
@@ -34,7 +34,7 @@ repos:
3434
hooks:
3535
- id: prettier
3636
additional_dependencies:
37-
- prettier@3.6.2
37+
- prettier@3.8.1
3838
- "@prettier/plugin-xml@3.4.2"
3939
- repo: meta
4040
hooks:

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Downloads](https://static.pepy.tech/badge/pytest-env/month)](https://pepy.tech/project/pytest-env)
77

88
A `pytest` plugin that sets environment variables from `pyproject.toml`, `pytest.toml`, `.pytest.toml`, or `pytest.ini`
9-
configuration files.
9+
configuration files. It can also load variables from `.env` files.
1010

1111
## Installation
1212

@@ -104,6 +104,43 @@ Running `pytest tests_integration/` uses `DB_HOST = "test-db"` from the subdirec
104104

105105
If no TOML file with a `pytest_env` section is found, the plugin falls back to the INI-style `env` key.
106106

107+
### Loading `.env` files
108+
109+
Use `env_files` to load variables from `.env` files. Files are loaded before inline `env` entries, so inline config
110+
takes precedence. Missing files are silently skipped. Paths are relative to the project root.
111+
112+
```toml
113+
# pyproject.toml
114+
[tool.pytest_env]
115+
env_files = [".env", ".env.test"]
116+
API_KEY = "override_value"
117+
```
118+
119+
```toml
120+
# pytest.toml or .pytest.toml
121+
[pytest_env]
122+
env_files = [".env"]
123+
```
124+
125+
```ini
126+
# pytest.ini
127+
[pytest]
128+
env_files =
129+
.env
130+
.env.test
131+
```
132+
133+
Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv), supporting `KEY=VALUE` lines, `#`
134+
comments, `export` prefix, quoted values (with escape sequences in double quotes), and `${VAR:-default}` expansion:
135+
136+
```shell
137+
# .env
138+
DATABASE_URL=postgres://localhost/mydb
139+
export SECRET_KEY='my-secret-key'
140+
DEBUG="true"
141+
MESSAGE="hello\nworld"
142+
```
143+
107144
### Examples
108145

109146
**Expanding environment variables** — reference existing variables using `{VAR}` syntax:

pyproject.toml

Lines changed: 5 additions & 4 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.27",
5+
"hatchling>=1.28",
66
]
77

88
[project]
@@ -36,12 +36,13 @@ dynamic = [
3636
"version",
3737
]
3838
dependencies = [
39-
"pytest>=9",
40-
"tomli>=2.2.1; python_version<'3.11'",
39+
"pytest>=9.0.2",
40+
"python-dotenv>=1.2.1",
41+
"tomli>=2.4; python_version<'3.11'",
4142
]
4243
optional-dependencies.testing = [
4344
"covdefaults>=2.3",
44-
"coverage>=7.10.7",
45+
"coverage>=7.13.4",
4546
"pytest-mock>=3.15.1",
4647
]
4748
urls.Homepage = "https://github.com/pytest-dev/pytest-env"

src/pytest_env/plugin.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
from typing import TYPE_CHECKING, Any
1010

1111
import pytest
12+
from dotenv import dotenv_values
1213

1314
if TYPE_CHECKING:
1415
from collections.abc import Generator, Iterator
16+
from pathlib import Path
1517

1618
if sys.version_info >= (3, 11): # pragma: >=3.11 cover
1719
import tomllib
@@ -23,6 +25,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
2325
"""Add section to configuration files."""
2426
help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE"
2527
parser.addini("env", type="linelist", help=help_msg, default=[])
28+
parser.addini("env_files", type="linelist", help="a line separated list of .env files to load", default=[])
2629

2730

2831
@dataclass
@@ -43,6 +46,10 @@ def pytest_load_initial_conftests(
4346
parser: pytest.Parser, # noqa: ARG001
4447
) -> None:
4548
"""Load environment variables from configuration files."""
49+
for env_file in _load_env_files(early_config):
50+
for key, value in dotenv_values(env_file).items():
51+
if value is not None:
52+
os.environ[key] = value
4653
for entry in _load_values(early_config):
4754
if entry.unset:
4855
os.environ.pop(entry.key, None)
@@ -53,8 +60,41 @@ def pytest_load_initial_conftests(
5360
os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value
5461

5562

63+
def _env_files_from_toml(early_config: pytest.Config) -> list[str]:
64+
for path in chain.from_iterable([[early_config.rootpath], early_config.rootpath.parents]):
65+
for pytest_toml_name in ("pytest.toml", ".pytest.toml", "pyproject.toml"):
66+
pytest_toml_file = path / pytest_toml_name
67+
if not pytest_toml_file.exists():
68+
continue
69+
with pytest_toml_file.open("rb") as file_handler:
70+
try:
71+
config = tomllib.load(file_handler)
72+
except tomllib.TOMLDecodeError:
73+
return []
74+
if pytest_toml_name == "pyproject.toml":
75+
config = config.get("tool", {})
76+
if (
77+
(pytest_env := config.get("pytest_env"))
78+
and isinstance(pytest_env, dict)
79+
and (raw := pytest_env.get("env_files"))
80+
):
81+
return [str(f) for f in (raw if isinstance(raw, list) else [raw])]
82+
return []
83+
return []
84+
85+
86+
def _load_env_files(early_config: pytest.Config) -> Generator[Path, None, None]:
87+
if not (env_files := _env_files_from_toml(early_config)):
88+
env_files = list(early_config.getini("env_files"))
89+
for env_file_str in env_files:
90+
if (resolved := early_config.rootpath / env_file_str).is_file():
91+
yield resolved
92+
93+
5694
def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
5795
for key, entry in config.items():
96+
if key == "env_files" and isinstance(entry, list):
97+
continue
5898
if isinstance(entry, dict):
5999
unset = bool(entry.get("unset"))
60100
value = str(entry.get("value", "")) if not unset else ""

tests/test_env.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
import os
44
import re
55
from pathlib import Path
6+
from typing import TYPE_CHECKING
67
from unittest import mock
78

89
import pytest
910

11+
from pytest_env.plugin import _env_files_from_toml # noqa: PLC2701
12+
13+
if TYPE_CHECKING:
14+
import pytest_mock
15+
1016

1117
@pytest.mark.parametrize(
1218
("env", "ini", "expected_env"),
@@ -318,6 +324,176 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
318324
result.assert_outcomes(passed=1)
319325

320326

327+
@pytest.mark.parametrize(
328+
("env", "env_file_content", "config", "expected_env", "config_type"),
329+
[
330+
pytest.param(
331+
{},
332+
"MAGIC=alpha\nSORCERY=beta",
333+
'[tool.pytest_env]\nenv_files = [".env"]',
334+
{"MAGIC": "alpha", "SORCERY": "beta"},
335+
"pyproject",
336+
id="basic env file via pyproject toml",
337+
),
338+
pytest.param(
339+
{},
340+
"MAGIC=alpha\nSORCERY=beta",
341+
'[pytest_env]\nenv_files = [".env"]',
342+
{"MAGIC": "alpha", "SORCERY": "beta"},
343+
"pytest.toml",
344+
id="basic env file via pytest toml",
345+
),
346+
pytest.param(
347+
{},
348+
"MAGIC=alpha\nSORCERY=beta",
349+
"[pytest]\nenv_files = .env",
350+
{"MAGIC": "alpha", "SORCERY": "beta"},
351+
"ini",
352+
id="basic env file via ini",
353+
),
354+
pytest.param(
355+
{},
356+
"# comment line\n\nMAGIC=alpha\n # indented comment\n",
357+
'[tool.pytest_env]\nenv_files = [".env"]',
358+
{"MAGIC": "alpha"},
359+
"pyproject",
360+
id="comments and blank lines",
361+
),
362+
pytest.param(
363+
{},
364+
"SINGLE='hello world'\nDOUBLE=\"hello world\"",
365+
'[tool.pytest_env]\nenv_files = [".env"]',
366+
{"SINGLE": "hello world", "DOUBLE": "hello world"},
367+
"pyproject",
368+
id="quoted values",
369+
),
370+
pytest.param(
371+
{},
372+
"MAGIC=alpha",
373+
'[tool.pytest_env]\nenv_files = [".env"]\nMAGIC = "beta"',
374+
{"MAGIC": "beta"},
375+
"pyproject",
376+
id="inline overrides env file",
377+
),
378+
pytest.param(
379+
{},
380+
"",
381+
'[tool.pytest_env]\nenv_files = ["missing.env"]',
382+
{},
383+
"pyproject",
384+
id="missing env file is skipped",
385+
),
386+
pytest.param(
387+
{},
388+
"KEY_ONLY\nVALID=yes",
389+
'[tool.pytest_env]\nenv_files = [".env"]',
390+
{"VALID": "yes"},
391+
"pyproject",
392+
id="line without equals is skipped",
393+
),
394+
pytest.param(
395+
{},
396+
"MAGIC=has=equals",
397+
'[tool.pytest_env]\nenv_files = [".env"]',
398+
{"MAGIC": "has=equals"},
399+
"pyproject",
400+
id="value with equals sign",
401+
),
402+
pytest.param(
403+
{},
404+
" MAGIC = alpha ",
405+
'[tool.pytest_env]\nenv_files = [".env"]',
406+
{"MAGIC": "alpha"},
407+
"pyproject",
408+
id="whitespace around key and value",
409+
),
410+
pytest.param(
411+
{"MAGIC": "original"},
412+
"MAGIC=from_file",
413+
'[tool.pytest_env]\nenv_files = [".env"]\nMAGIC = {value = "from_file", skip_if_set = true}',
414+
{"MAGIC": "from_file"},
415+
"pyproject",
416+
id="skip if set respects env file",
417+
),
418+
pytest.param(
419+
{},
420+
"=no_key\nVALID=yes",
421+
'[tool.pytest_env]\nenv_files = [".env"]',
422+
{"VALID": "yes"},
423+
"pyproject",
424+
id="empty key is skipped",
425+
),
426+
pytest.param(
427+
{},
428+
"",
429+
'[tool.pytest_env]\nenv_files = "some_value"',
430+
{"env_files": "some_value"},
431+
"pyproject",
432+
id="env_files as env var when string",
433+
),
434+
pytest.param(
435+
{},
436+
"export MAGIC=alpha",
437+
'[tool.pytest_env]\nenv_files = [".env"]',
438+
{"MAGIC": "alpha"},
439+
"pyproject",
440+
id="export prefix",
441+
),
442+
pytest.param(
443+
{},
444+
'MAGIC="hello\\nworld"',
445+
'[tool.pytest_env]\nenv_files = [".env"]',
446+
{"MAGIC": "hello\nworld"},
447+
"pyproject",
448+
id="escape sequences in double quotes",
449+
),
450+
pytest.param(
451+
{},
452+
"MAGIC=alpha #comment",
453+
'[tool.pytest_env]\nenv_files = [".env"]',
454+
{"MAGIC": "alpha"},
455+
"pyproject",
456+
id="inline comment",
457+
),
458+
],
459+
)
460+
def test_env_via_env_file( # noqa: PLR0913, PLR0917
461+
testdir: pytest.Testdir,
462+
env: dict[str, str],
463+
env_file_content: str,
464+
config: str,
465+
expected_env: dict[str, str | None],
466+
config_type: str,
467+
request: pytest.FixtureRequest,
468+
) -> None:
469+
tmp_dir = Path(str(testdir.tmpdir))
470+
test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower()
471+
Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
472+
if env_file_content:
473+
(tmp_dir / ".env").write_text(env_file_content, encoding="utf-8")
474+
config_file_names = {"pyproject": "pyproject.toml", "pytest.toml": "pytest.toml", "ini": "pytest.ini"}
475+
(tmp_dir / config_file_names[config_type]).write_text(config, encoding="utf-8")
476+
477+
new_env = {
478+
**env,
479+
"_TEST_ENV": repr(expected_env),
480+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
481+
"PYTEST_PLUGINS": "pytest_env.plugin",
482+
}
483+
484+
with mock.patch.dict(os.environ, new_env, clear=True):
485+
result = testdir.runpytest()
486+
487+
result.assert_outcomes(passed=1)
488+
489+
490+
def test_env_files_from_toml_bad_toml(tmp_path: Path, mocker: pytest_mock.MockerFixture) -> None:
491+
(tmp_path / "pyproject.toml").write_text("bad toml", encoding="utf-8")
492+
config = mocker.MagicMock()
493+
config.rootpath = tmp_path
494+
assert _env_files_from_toml(config) == []
495+
496+
321497
@pytest.mark.parametrize("toml_name", ["pytest.toml", ".pytest.toml", "pyproject.toml"])
322498
def test_env_via_pyproject_toml_bad(testdir: pytest.Testdir, toml_name: str) -> None:
323499
toml_file = Path(str(testdir.tmpdir)) / toml_name

0 commit comments

Comments
 (0)