Skip to content

Commit 81a2692

Browse files
authored
Add native toml support (#59)
1 parent e67abc4 commit 81a2692

File tree

6 files changed

+167
-23
lines changed

6 files changed

+167
-23
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
rev: "1.2.0"
2323
hooks:
2424
- id: pyproject-fmt
25-
additional_dependencies: ["tox>=4.10"]
25+
additional_dependencies: ["tox>=4.11.3"]
2626
- repo: https://github.com/pre-commit/mirrors-prettier
2727
rev: "v3.0.3"
2828
hooks:

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ pip install pytest-env
2020

2121
## Usage
2222

23+
### Native form in `pyproject.toml`
24+
25+
```toml
26+
[tool.pytest_env]
27+
HOME = "~/tmp"
28+
RUN_ENV = 1
29+
TRANSFORMED = {value = "{USER}/alpha", transform: true}
30+
SKIP_IF_SET = {value = "on", skip_if_set: true}
31+
```
32+
33+
The `tool.pytest_env` tables keys are the environment variables keys to set. The right hands-ide of the assigment:
34+
35+
- if an inline table you can set options via the `transform` or `skip_if_set` keys, while the `value` key holds the
36+
value to set (or transform before setting). For transformation the variables you can use is other environment
37+
variable,
38+
- otherwise the value to set for the environment variable to set (casted to a string).
39+
40+
### Via pytest configurations
41+
2342
In your pytest.ini file add a key value pair with `env` as the key and the environment variables as a line separated
2443
list of `KEY=VALUE` entries. The defined variables will be added to the environment before any tests are run:
2544

pyproject.toml

+8-5
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ dynamic = [
3535
"version",
3636
]
3737
dependencies = [
38-
"pytest>=7.4",
38+
"pytest>=7.4.2",
39+
'tomli>=2.0.1; python_version < "3.11"',
3940
]
4041
optional-dependencies.test = [
41-
"coverage>=7.3",
42-
"pytest-mock>=3.11.1",
42+
"covdefaults>=2.3",
43+
"coverage>=7.3.2",
44+
"pytest-mock>=3.12",
4345
]
4446
urls.Homepage = "https://github.com/pytest-dev/pytest-env"
4547
urls.Source = "https://github.com/pytest-dev/pytest-env"
@@ -85,7 +87,8 @@ run.source = ["pytest_env", "tests"]
8587
run.dynamic_context = "test_function"
8688
run.branch = true
8789
run.parallel = true
88-
report.fail_under = 92
90+
run.plugins = ["covdefaults"]
91+
report.fail_under = 100
8992
report.show_missing = true
9093
html.show_contexts = true
9194
html.skip_covered = false
@@ -99,6 +102,6 @@ paths.source = [
99102
]
100103

101104
[tool.mypy]
102-
python_version = "3.10"
105+
python_version = "3.11"
103106
show_error_codes = true
104107
strict = true

src/pytest_env/plugin.py

+55-14
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,77 @@
22
from __future__ import annotations
33

44
import os
5+
import sys
6+
from dataclasses import dataclass
7+
from itertools import chain
8+
from typing import Iterator
59

610
import pytest
711

12+
if sys.version_info >= (3, 11): # pragma: >=3.11 cover
13+
import tomllib
14+
else: # pragma: <3.11 cover
15+
import tomli as tomllib
16+
817

918
def pytest_addoption(parser: pytest.Parser) -> None:
1019
"""Add section to configuration files."""
1120
help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE"
1221
parser.addini("env", type="linelist", help=help_msg, default=[])
1322

1423

24+
@dataclass
25+
class Entry:
26+
"""Configuration entries."""
27+
28+
key: str
29+
value: str
30+
transform: bool
31+
skip_if_set: bool
32+
33+
1534
@pytest.hookimpl(tryfirst=True)
1635
def pytest_load_initial_conftests(
1736
args: list[str], # noqa: ARG001
1837
early_config: pytest.Config,
1938
parser: pytest.Parser, # noqa: ARG001
2039
) -> None:
2140
"""Load environment variables from configuration files."""
22-
for line in early_config.getini("env"):
23-
# INI lines e.g. D:R:NAME=VAL has two flags (R and D), NAME key, and VAL value
24-
parts = line.partition("=")
25-
ini_key_parts = parts[0].split(":")
26-
flags = {k.strip().upper() for k in ini_key_parts[:-1]}
27-
# R: is a way to designate whether to use raw value -> perform no transformation of the value
28-
transform = "R" not in flags
29-
# D: is a way to mark the value to be set only if it does not exist yet
30-
skip_if_set = "D" in flags
31-
key = ini_key_parts[-1].strip()
32-
value = parts[2].strip()
33-
34-
if skip_if_set and key in os.environ:
41+
for entry in _load_values(early_config):
42+
if entry.skip_if_set and entry.key in os.environ:
3543
continue
3644
# transformation -> replace environment variables, e.g. TEST_DIR={USER}/repo_test_dir.
37-
os.environ[key] = value.format(**os.environ) if transform else value
45+
os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value
46+
47+
48+
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
49+
has_toml_conf = False
50+
for path in chain.from_iterable([[early_config.rootpath], early_config.rootpath.parents]):
51+
toml_file = path / "pyproject.toml"
52+
if toml_file.exists():
53+
with toml_file.open("rb") as file_handler:
54+
config = tomllib.load(file_handler)
55+
if "tool" in config and "pytest_env" in config["tool"]:
56+
has_toml_conf = True
57+
for key, entry in config["tool"]["pytest_env"].get("env", {}).items():
58+
if isinstance(entry, dict):
59+
value = str(entry["value"])
60+
transform, skip_if_set = bool(entry.get("transform")), bool(entry.get("skip_if_set"))
61+
else:
62+
value, transform, skip_if_set = str(entry), False, False
63+
yield Entry(key, value, transform, skip_if_set)
64+
break
65+
66+
if not has_toml_conf:
67+
for line in early_config.getini("env"):
68+
# INI lines e.g. D:R:NAME=VAL has two flags (R and D), NAME key, and VAL value
69+
parts = line.partition("=")
70+
ini_key_parts = parts[0].split(":")
71+
flags = {k.strip().upper() for k in ini_key_parts[:-1]}
72+
# R: is a way to designate whether to use raw value -> perform no transformation of the value
73+
transform = "R" not in flags
74+
# D: is a way to mark the value to be set only if it does not exist yet
75+
skip_if_set = "D" in flags
76+
key = ini_key_parts[-1].strip()
77+
value = parts[2].strip()
78+
yield Entry(key, value, transform, skip_if_set)

tests/test_env.py

+82-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
),
9393
],
9494
)
95-
def test_env(
95+
def test_env_via_pytest(
9696
testdir: pytest.Testdir,
9797
env: dict[str, str],
9898
ini: str,
@@ -116,3 +116,84 @@ def test_env(
116116
result = testdir.runpytest()
117117

118118
result.assert_outcomes(passed=1)
119+
120+
121+
@pytest.mark.parametrize(
122+
("env", "toml", "ini", "expected_env"),
123+
[
124+
pytest.param(
125+
{},
126+
'[tool.pytest.ini_options]\nenv = ["MAGIC=toml", "MAGIC_2=toml2"]',
127+
"[pytest]\nenv = MAGIC=ini\n MAGIC_2=ini2",
128+
{"MAGIC": "ini", "MAGIC_2": "ini2"},
129+
id="ini over toml ini_options",
130+
),
131+
pytest.param(
132+
{},
133+
'[tool.pytest.ini_options]\nenv = ["MAGIC=toml", "MAGIC_2=toml2"]',
134+
"",
135+
{"MAGIC": "toml", "MAGIC_2": "toml2"},
136+
id="toml via ini_options",
137+
),
138+
pytest.param(
139+
{},
140+
'[tool.pytest_env.env]\nMAGIC = 1\nMAGIC_2 = "toml2"',
141+
"",
142+
{"MAGIC": "1", "MAGIC_2": "toml2"},
143+
id="toml native",
144+
),
145+
pytest.param(
146+
{},
147+
'[tool.pytest_env.env]\nMAGIC = 1\nMAGIC_2 = "toml2"',
148+
"[pytest]\nenv = MAGIC=ini\n MAGIC_2=ini2",
149+
{"MAGIC": "1", "MAGIC_2": "toml2"},
150+
id="toml native over ini",
151+
),
152+
pytest.param(
153+
{},
154+
'[tool.pytest_env.env]\nMAGIC = {value = "toml", "transform"= true, "skip_if_set" = true}',
155+
"",
156+
{"MAGIC": "toml"},
157+
id="toml inline table",
158+
),
159+
],
160+
)
161+
def test_env_via_toml( # noqa: PLR0913
162+
testdir: pytest.Testdir,
163+
env: dict[str, str],
164+
toml: str,
165+
ini: str,
166+
expected_env: dict[str, str],
167+
request: pytest.FixtureRequest,
168+
) -> None:
169+
tmp_dir = Path(str(testdir.tmpdir))
170+
test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower()
171+
Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
172+
if ini:
173+
(tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8")
174+
(tmp_dir / "pyproject.toml").write_text(toml, encoding="utf-8")
175+
176+
new_env = {
177+
**env,
178+
"_TEST_ENV": repr(expected_env),
179+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
180+
"PYTEST_PLUGINS": "pytest_env.plugin",
181+
}
182+
183+
# monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict
184+
with mock.patch.dict(os.environ, new_env, clear=True):
185+
result = testdir.runpytest()
186+
187+
result.assert_outcomes(passed=1)
188+
189+
190+
def test_env_via_toml_bad(testdir: pytest.Testdir) -> None:
191+
toml_file = Path(str(testdir.tmpdir)) / "pyproject.toml"
192+
toml_file.write_text("bad toml", encoding="utf-8")
193+
194+
result = testdir.runpytest()
195+
assert result.ret == 4
196+
assert result.errlines == [
197+
f"ERROR: {toml_file}: Expected '=' after a key in a key/value pair (at line 1, column 5)",
198+
"",
199+
]

tox.ini

+2-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ commands =
4343
[testenv:type]
4444
description = run type check on code base
4545
deps =
46-
mypy==1.5.1
46+
mypy==1.6.1
4747
set_env =
4848
{tty:MYPY_FORCE_COLOR = 1}
4949
commands =
@@ -54,7 +54,7 @@ commands =
5454
description = check that the long description is valid
5555
skip_install = true
5656
deps =
57-
build[virtualenv]>=0.10
57+
build[virtualenv]>=1.0.3
5858
twine>=4.0.2
5959
change_dir = {toxinidir}
6060
commands =

0 commit comments

Comments
 (0)