Skip to content

Commit 16158a4

Browse files
authored
♻️ refactor(plugin): consolidate TOML config parsing (#193)
1 parent 20f4890 commit 16158a4

File tree

3 files changed

+91
-98
lines changed

3 files changed

+91
-98
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,12 @@ lint.select = [
6363
"ALL",
6464
]
6565
lint.ignore = [
66-
"ANN101", # no type annotation for self
6766
"COM812", # Conflict with formatter
6867
"CPY", # No copyright statements
6968
"D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible
7069
"D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible
70+
"DOC201", # `return` is not documented in docstring (we prefer minimal docs)
71+
"DOC402", # `yield` is not documented in docstring (we prefer minimal docs)
7172
"ISC001", # Conflict with formatter
7273
"S104", # Possible binding to all interface
7374
]

src/pytest_env/plugin.py

Lines changed: 63 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import os
66
import sys
77
from dataclasses import dataclass
8-
from itertools import chain
98
from typing import TYPE_CHECKING, Any
109

1110
import pytest
@@ -46,7 +45,11 @@ def pytest_load_initial_conftests(
4645
parser: pytest.Parser, # noqa: ARG001
4746
) -> None:
4847
"""Load environment variables from configuration files."""
49-
for env_file in _load_env_files(early_config):
48+
env_files_list: list[str] = []
49+
if toml_config := _find_toml_config(early_config):
50+
env_files_list, _ = _load_toml_config(toml_config)
51+
52+
for env_file in _load_env_files(early_config, env_files_list):
5053
for key, value in dotenv_values(env_file).items():
5154
if value is not None:
5255
os.environ[key] = value
@@ -56,77 +59,62 @@ def pytest_load_initial_conftests(
5659
elif entry.skip_if_set and entry.key in os.environ:
5760
continue
5861
else:
59-
# transformation -> replace environment variables, e.g. TEST_DIR={USER}/repo_test_dir.
6062
os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value
6163

6264

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
65+
def _find_toml_config(early_config: pytest.Config) -> Path | None:
66+
"""Find TOML config file by checking inipath first, then walking up the tree."""
67+
if (
68+
early_config.inipath
69+
and early_config.inipath.suffix == ".toml"
70+
and early_config.inipath.name in {"pytest.toml", ".pytest.toml", "pyproject.toml"}
71+
):
72+
return early_config.inipath
9273

74+
start_path = early_config.inipath.parent if early_config.inipath is not None else early_config.rootpath
75+
for current_path in [start_path, *start_path.parents]:
76+
for toml_name in ("pytest.toml", ".pytest.toml", "pyproject.toml"):
77+
toml_file = current_path / toml_name
78+
if toml_file.exists():
79+
return toml_file
80+
return None
9381

94-
def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
95-
for key, entry in config.items():
96-
if key == "env_files" and isinstance(entry, list):
97-
continue
98-
if isinstance(entry, dict):
99-
unset = bool(entry.get("unset"))
100-
value = str(entry.get("value", "")) if not unset else ""
101-
transform, skip_if_set = bool(entry.get("transform")), bool(entry.get("skip_if_set"))
102-
else:
103-
value, transform, skip_if_set, unset = str(entry), False, False, False
104-
yield Entry(key, value, transform, skip_if_set, unset=unset)
10582

83+
def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]:
84+
"""Load env_files and entries from TOML config file."""
85+
with config_path.open("rb") as file_handler:
86+
config = tomllib.load(file_handler)
10687

107-
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
108-
has_toml = False
109-
start_path = early_config.inipath.parent if early_config.inipath is not None else early_config.rootpath
110-
for path in chain.from_iterable([[start_path], start_path.parents]):
111-
for pytest_toml_name in ("pytest.toml", ".pytest.toml", "pyproject.toml"):
112-
pytest_toml_file = path / pytest_toml_name
113-
if pytest_toml_file.exists():
114-
with pytest_toml_file.open("rb") as file_handler:
115-
config = tomllib.load(file_handler)
88+
if config_path.name == "pyproject.toml":
89+
config = config.get("tool", {})
90+
91+
pytest_env_config = config.get("pytest_env", {})
92+
if not pytest_env_config:
93+
return [], []
11694

117-
if pytest_toml_name == "pyproject.toml": # in pyproject.toml the path is tool.pytest_env
118-
config = config.get("tool", {})
95+
raw_env_files = pytest_env_config.get("env_files")
96+
env_files = [str(f) for f in raw_env_files] if isinstance(raw_env_files, list) else []
11997

120-
if "pytest_env" in config:
121-
has_toml = True
122-
yield from _parse_toml_config(config["pytest_env"])
98+
entries = list(_parse_toml_config(pytest_env_config))
99+
return env_files, entries
123100

124-
break # breaks the pytest_toml_name forloop
125-
if has_toml: # breaks the path forloop
126-
break
127101

128-
if has_toml:
129-
return
102+
def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]:
103+
"""Resolve and yield existing env files."""
104+
if not env_files:
105+
env_files = list(early_config.getini("env_files"))
106+
for env_file_str in env_files:
107+
if (resolved := early_config.rootpath / env_file_str).is_file():
108+
yield resolved
109+
110+
111+
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
112+
"""Load env entries from config, preferring TOML over INI."""
113+
if toml_config := _find_toml_config(early_config):
114+
_, entries = _load_toml_config(toml_config)
115+
if entries:
116+
yield from entries
117+
return
130118

131119
for line in early_config.getini("env"):
132120
# INI lines e.g. D:R:NAME=VAL has two flags (R and D), NAME key, and VAL value
@@ -142,3 +130,16 @@ def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
142130
key = ini_key_parts[-1].strip()
143131
value = parts[2].strip()
144132
yield Entry(key, value, transform, skip_if_set, unset=unset)
133+
134+
135+
def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
136+
for key, entry in config.items():
137+
if key == "env_files" and isinstance(entry, list):
138+
continue
139+
if isinstance(entry, dict):
140+
unset = bool(entry.get("unset"))
141+
value = str(entry.get("value", "")) if not unset else ""
142+
transform, skip_if_set = bool(entry.get("transform")), bool(entry.get("skip_if_set"))
143+
else:
144+
value, transform, skip_if_set, unset = str(entry), False, False, False
145+
yield Entry(key, value, transform, skip_if_set, unset=unset)

tests/test_env.py

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@
33
import os
44
import re
55
from pathlib import Path
6-
from typing import TYPE_CHECKING
76
from unittest import mock
87

98
import pytest
109

11-
from pytest_env.plugin import _env_files_from_toml # noqa: PLC2701
12-
13-
if TYPE_CHECKING:
14-
import pytest_mock
10+
from pytest_env.plugin import _load_toml_config # noqa: PLC2701
1511

1612

1713
@pytest.mark.parametrize(
@@ -117,16 +113,15 @@
117113
],
118114
)
119115
def test_env_via_pytest(
120-
testdir: pytest.Testdir,
116+
pytester: pytest.Pytester,
121117
env: dict[str, str],
122118
ini: str,
123119
expected_env: dict[str, str],
124120
request: pytest.FixtureRequest,
125121
) -> None:
126-
tmp_dir = Path(str(testdir.tmpdir))
127122
test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower()
128-
Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
129-
(tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8")
123+
(pytester.path / f"test_{test_name}.py").symlink_to(Path(__file__).parent / "template.py")
124+
(pytester.path / "pytest.ini").write_text(ini, encoding="utf-8")
130125

131126
new_env = {
132127
**env,
@@ -135,9 +130,8 @@ def test_env_via_pytest(
135130
"PYTEST_PLUGINS": "pytest_env.plugin",
136131
}
137132

138-
# monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict
139133
with mock.patch.dict(os.environ, new_env, clear=True):
140-
result = testdir.runpytest()
134+
result = pytester.runpytest()
141135

142136
result.assert_outcomes(passed=1)
143137

@@ -283,7 +277,7 @@ def test_env_via_pytest(
283277
],
284278
)
285279
def test_env_via_toml( # noqa: PLR0913, PLR0917
286-
testdir: pytest.Testdir,
280+
pytester: pytest.Pytester,
287281
env: dict[str, str],
288282
pyproject_toml: str,
289283
pytest_toml: str,
@@ -292,23 +286,22 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
292286
pytest_toml_name: str | None,
293287
request: pytest.FixtureRequest,
294288
) -> None:
295-
tmp_dir = Path(str(testdir.tmpdir))
296289
test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower()
297290
if pyproject_toml:
298-
(tmp_dir / "pyproject.toml").write_text(pyproject_toml, encoding="utf-8")
291+
(pytester.path / "pyproject.toml").write_text(pyproject_toml, encoding="utf-8")
299292
if pytest_toml and pytest_toml_name:
300-
toml_path = tmp_dir / pytest_toml_name
293+
toml_path = pytester.path / pytest_toml_name
301294
toml_path.parent.mkdir(parents=True, exist_ok=True)
302295
toml_path.write_text(pytest_toml, encoding="utf-8")
303296

304297
if pytest_toml_name and "/" in pytest_toml_name:
305-
test_dir = tmp_dir / Path(pytest_toml_name).parent
298+
test_dir = pytester.path / Path(pytest_toml_name).parent
306299
else:
307-
test_dir = tmp_dir
300+
test_dir = pytester.path
308301
if ini:
309-
(tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8")
302+
(pytester.path / "pytest.ini").write_text(ini, encoding="utf-8")
310303

311-
Path(str(test_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
304+
(test_dir / f"test_{test_name}.py").symlink_to(Path(__file__).parent / "template.py")
312305

313306
new_env = {
314307
**env,
@@ -317,9 +310,8 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
317310
"PYTEST_PLUGINS": "pytest_env.plugin",
318311
}
319312

320-
# monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict
321313
with mock.patch.dict(os.environ, new_env, clear=True):
322-
result = testdir.runpytest(str(test_dir))
314+
result = pytester.runpytest(str(test_dir))
323315

324316
result.assert_outcomes(passed=1)
325317

@@ -458,21 +450,20 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
458450
],
459451
)
460452
def test_env_via_env_file( # noqa: PLR0913, PLR0917
461-
testdir: pytest.Testdir,
453+
pytester: pytest.Pytester,
462454
env: dict[str, str],
463455
env_file_content: str,
464456
config: str,
465457
expected_env: dict[str, str | None],
466458
config_type: str,
467459
request: pytest.FixtureRequest,
468460
) -> None:
469-
tmp_dir = Path(str(testdir.tmpdir))
470461
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")
462+
(pytester.path / f"test_{test_name}.py").symlink_to(Path(__file__).parent / "template.py")
472463
if env_file_content:
473-
(tmp_dir / ".env").write_text(env_file_content, encoding="utf-8")
464+
(pytester.path / ".env").write_text(env_file_content, encoding="utf-8")
474465
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")
466+
(pytester.path / config_file_names[config_type]).write_text(config, encoding="utf-8")
476467

477468
new_env = {
478469
**env,
@@ -482,24 +473,24 @@ def test_env_via_env_file( # noqa: PLR0913, PLR0917
482473
}
483474

484475
with mock.patch.dict(os.environ, new_env, clear=True):
485-
result = testdir.runpytest()
476+
result = pytester.runpytest()
486477

487478
result.assert_outcomes(passed=1)
488479

489480

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) == []
481+
def test_env_files_from_toml_bad_toml(tmp_path: Path) -> None:
482+
toml_file = tmp_path / "pyproject.toml"
483+
toml_file.write_text("bad toml", encoding="utf-8")
484+
with pytest.raises(Exception, match="Expected '=' after a key"):
485+
_load_toml_config(toml_file)
495486

496487

497488
@pytest.mark.parametrize("toml_name", ["pytest.toml", ".pytest.toml", "pyproject.toml"])
498-
def test_env_via_pyproject_toml_bad(testdir: pytest.Testdir, toml_name: str) -> None:
499-
toml_file = Path(str(testdir.tmpdir)) / toml_name
489+
def test_env_via_pyproject_toml_bad(pytester: pytest.Pytester, toml_name: str) -> None:
490+
toml_file = pytester.path / toml_name
500491
toml_file.write_text("bad toml", encoding="utf-8")
501492

502-
result = testdir.runpytest()
493+
result = pytester.runpytest()
503494
assert result.ret == 4
504495
assert result.errlines == [
505496
f"ERROR: {toml_file}: Expected '=' after a key in a key/value pair (at line 1, column 5)",

0 commit comments

Comments
 (0)