Skip to content

Commit 9e52830

Browse files
authored
🐛 fix(config): use inipath for TOML file discovery (#190)
1 parent 17f5568 commit 9e52830

File tree

3 files changed

+99
-49
lines changed

3 files changed

+99
-49
lines changed

README.md

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,24 @@
55
[![check](https://github.com/pytest-dev/pytest-env/actions/workflows/check.yaml/badge.svg)](https://github.com/pytest-dev/pytest-env/actions/workflows/check.yaml)
66
[![Downloads](https://static.pepy.tech/badge/pytest-env/month)](https://pepy.tech/project/pytest-env)
77

8-
This is a `pytest` plugin that enables you to set environment variables in `pytest.ini`, `pyproject.toml`, `pytest.toml`
9-
or `.pytest.toml` files.
8+
A `pytest` plugin that sets environment variables from `pyproject.toml`, `pytest.toml`, `.pytest.toml`, or `pytest.ini`
9+
configuration files.
1010

1111
## Installation
1212

13-
Install with pip:
14-
1513
```shell
1614
pip install pytest-env
1715
```
1816

1917
## Usage
2018

21-
### Native form in `pyproject.toml`, `pytest.toml` and `.pytest.toml`
22-
23-
Native form takes precedence over the `pytest.ini` form. `pytest.toml` takes precedence over `.pytest.toml`, and that
24-
takes precedence over `pyproject.toml`.
19+
### TOML configuration (native form)
2520

26-
In `pyproject.toml`:
21+
Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` /
22+
`.pytest.toml`:
2723

2824
```toml
25+
# pyproject.toml
2926
[tool.pytest_env]
3027
HOME = "~/tmp"
3128
RUN_ENV = 1
@@ -34,9 +31,8 @@ SKIP_IF_SET = { value = "on", skip_if_set = true }
3431
DATABASE_URL = { unset = true }
3532
```
3633

37-
In `pytest.toml` (or `.pytest.toml`):
38-
3934
```toml
35+
# pytest.toml or .pytest.toml
4036
[pytest_env]
4137
HOME = "~/tmp"
4238
RUN_ENV = 1
@@ -45,93 +41,129 @@ SKIP_IF_SET = { value = "on", skip_if_set = true }
4541
DATABASE_URL = { unset = true }
4642
```
4743

48-
The `tool.pytest_env` (`pytest_env` in `pytest.toml` and `.pytest.toml`) tables keys are the environment variables keys
49-
to set. The right hand side of the assignment:
44+
Each key is the environment variable name. The value is either a plain value (cast to string) or an inline table with
45+
the following keys:
5046

51-
- if an inline table you can set options via the `transform`, `skip_if_set` or `unset` keys, while the `value` key holds
52-
the value to set (or transform before setting). For transformation the variables you can use is other environment
53-
variable,
54-
- otherwise the value to set for the environment variable to set (casted to a string).
47+
| Key | Type | Description |
48+
| ------------- | ------ | --------------------------------------------------------------------------- |
49+
| `value` | string | The value to set |
50+
| `transform` | bool | Expand `{VAR}` references in the value using existing environment variables |
51+
| `skip_if_set` | bool | Only set the variable if it is not already defined |
52+
| `unset` | bool | Remove the variable from the environment (ignores `value`) |
5553

56-
### Via pytest configurations
54+
### INI configuration
5755

58-
In your pytest.ini file add a key value pair with `env` as the key and the environment variables as a line separated
59-
list of `KEY=VALUE` entries. The defined variables will be added to the environment before any tests are run:
56+
Define environment variables as a line-separated list of `KEY=VALUE` entries under the `env` key:
6057

6158
```ini
59+
# pytest.ini
6260
[pytest]
6361
env =
6462
HOME=~/tmp
6563
RUN_ENV=test
6664
```
6765

68-
Or with `pyproject.toml`:
69-
7066
```toml
67+
# pyproject.toml
7168
[tool.pytest]
7269
env = [
7370
"HOME=~/tmp",
7471
"RUN_ENV=test",
7572
]
7673
```
7774

78-
### Only set if not already set
75+
Prefix flags modify behavior. Flags are case-insensitive and can be combined in any order (e.g., `R:D:KEY=VALUE`):
76+
77+
| Flag | Description |
78+
| ---- | ------------------------------------------------------------------ |
79+
| `D:` | Default — only set if the variable is not already defined |
80+
| `R:` | Raw — skip `{VAR}` expansion (INI expands by default, unlike TOML) |
81+
| `U:` | Unset — remove the variable from the environment entirely |
7982

80-
Use `skip_if_set = true` in the native TOML form, or the `D:` (default) prefix in INI form, to only set the variable
81-
when it is not already defined in the environment:
83+
### Precedence
84+
85+
When multiple configuration sources are present, the native TOML form takes precedence over the INI form. Within the
86+
TOML form, files are checked in this order: `pytest.toml`, `.pytest.toml`, `pyproject.toml`. The first file containing a
87+
`pytest_env` section wins.
88+
89+
### Configuration file discovery
90+
91+
The plugin walks the directory tree starting from the directory containing the configuration file pytest resolved
92+
(`inipath`). For each directory it checks `pytest.toml`, `.pytest.toml`, and `pyproject.toml` in order, stopping at the
93+
first file with a `pytest_env` section. This means a subdirectory config takes precedence over a parent config:
94+
95+
```
96+
project/
97+
├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db"
98+
└── tests_integration/
99+
├── pytest.toml # [pytest_env] DB_HOST = "test-db"
100+
└── test_api.py
101+
```
102+
103+
Running `pytest tests_integration/` uses `DB_HOST = "test-db"` from the subdirectory.
104+
105+
If no TOML file with a `pytest_env` section is found, the plugin falls back to the INI-style `env` key.
106+
107+
### Examples
108+
109+
**Expanding environment variables** — reference existing variables using `{VAR}` syntax:
82110

83111
```toml
84112
[pytest_env]
85-
HOME = { value = "~/tmp", skip_if_set = true }
86-
RUN_ENV = { value = "test", skip_if_set = true }
113+
RUN_PATH = { value = "/run/path/{USER}", transform = true }
87114
```
88115

89116
```ini
90117
[pytest]
91118
env =
92-
D:HOME=~/tmp
93-
D:RUN_ENV=test
119+
RUN_PATH=/run/path/{USER}
94120
```
95121

96-
### Transformation
122+
In TOML, expansion requires `transform = true`. In INI, expansion is the default; use the `R:` flag to disable it.
97123

98-
You can reference existing environment variables using a python-like format. Use `transform = true` in the native TOML
99-
form, or omit the `R:` prefix in INI form (transformation is the default in INI):
124+
**Keeping raw values** — prevent `{VAR}` expansion:
100125

101126
```toml
102127
[pytest_env]
103-
RUN_PATH = { value = "/run/path/{USER}", transform = true }
128+
PATTERN = { value = "/run/path/{USER}" }
104129
```
105130

106131
```ini
107132
[pytest]
108133
env =
109-
RUN_PATH=/run/path/{USER}
134+
R:PATTERN=/run/path/{USER}
110135
```
111136

112-
To keep the raw value and skip transformation, omit `transform` (or set it to `false`) in TOML, or apply the `R:` prefix
113-
in INI (can combine with `D:`/`skip_if_set`, order is not important):
137+
**Conditional defaults** — only set when not already defined:
114138

115139
```toml
116140
[pytest_env]
117-
RUN_PATH = { value = "/run/path/{USER}" }
118-
RUN_PATH_IF_NOT_SET = { value = "/run/path/{USER}", skip_if_set = true }
141+
HOME = { value = "~/tmp", skip_if_set = true }
119142
```
120143

121144
```ini
122145
[pytest]
123146
env =
124-
R:RUN_PATH=/run/path/{USER}
125-
R:D:RUN_PATH_IF_NOT_SET=/run/path/{USER}
147+
D:HOME=~/tmp
126148
```
127149

128-
### Unsetting variables
150+
**Unsetting variables** — completely remove a variable from `os.environ` (not the same as setting to empty string):
129151

130-
You can use `U:` (unset) as prefix to remove an environment variable. This differs from setting a variable to an empty
131-
string — the variable will be completely removed from `os.environ`:
152+
```toml
153+
[pytest_env]
154+
DATABASE_URL = { unset = true }
155+
```
132156

133157
```ini
134158
[pytest]
135159
env =
136160
U:DATABASE_URL
137161
```
162+
163+
**Combining flags** — flags can be combined in any order:
164+
165+
```ini
166+
[pytest]
167+
env =
168+
R:D:TEMPLATE=/path/{placeholder}
169+
```

src/pytest_env/plugin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
6666

6767
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
6868
has_toml = False
69-
for path in chain.from_iterable([[early_config.rootpath], early_config.rootpath.parents]):
69+
start_path = early_config.inipath.parent if early_config.inipath is not None else early_config.rootpath
70+
for path in chain.from_iterable([[start_path], start_path.parents]):
7071
for pytest_toml_name in ("pytest.toml", ".pytest.toml", "pyproject.toml"):
7172
pytest_toml_file = path / pytest_toml_name
7273
if pytest_toml_file.exists():

tests/test_env.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,15 @@ def test_env_via_pytest(
265265
None,
266266
id="pyproject toml unset non-existing",
267267
),
268+
pytest.param(
269+
{},
270+
'[tool.pytest_env]\nMAGIC = "parent"',
271+
'[pytest_env]\nMAGIC = "child"',
272+
"",
273+
{"MAGIC": "child"},
274+
"sub/pytest.toml",
275+
id="subdir pytest toml over parent pyproject toml",
276+
),
268277
],
269278
)
270279
def test_env_via_toml( # noqa: PLR0913, PLR0917
@@ -279,13 +288,21 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
279288
) -> None:
280289
tmp_dir = Path(str(testdir.tmpdir))
281290
test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower()
282-
Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
283-
if ini:
284-
(tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8")
285291
if pyproject_toml:
286292
(tmp_dir / "pyproject.toml").write_text(pyproject_toml, encoding="utf-8")
287293
if pytest_toml and pytest_toml_name:
288-
(tmp_dir / pytest_toml_name).write_text(pytest_toml, encoding="utf-8")
294+
toml_path = tmp_dir / pytest_toml_name
295+
toml_path.parent.mkdir(parents=True, exist_ok=True)
296+
toml_path.write_text(pytest_toml, encoding="utf-8")
297+
298+
if pytest_toml_name and "/" in pytest_toml_name:
299+
test_dir = tmp_dir / Path(pytest_toml_name).parent
300+
else:
301+
test_dir = tmp_dir
302+
if ini:
303+
(tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8")
304+
305+
Path(str(test_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
289306

290307
new_env = {
291308
**env,
@@ -296,7 +313,7 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917
296313

297314
# monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict
298315
with mock.patch.dict(os.environ, new_env, clear=True):
299-
result = testdir.runpytest()
316+
result = testdir.runpytest(str(test_dir))
300317

301318
result.assert_outcomes(passed=1)
302319

0 commit comments

Comments
 (0)