-
-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathplugin.py
More file actions
259 lines (215 loc) · 9.66 KB
/
plugin.py
File metadata and controls
259 lines (215 loc) · 9.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""Adopt environment section in pytest configuration files."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import pytest
from dotenv import dotenv_values
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
from pathlib import Path
_env_actions_key = pytest.StashKey[list[str]]()
if sys.version_info >= (3, 11): # pragma: >=3.11 cover
import tomllib
else: # pragma: <3.11 cover
import tomli as tomllib
def pytest_addoption(parser: pytest.Parser) -> None:
"""Add section to configuration files."""
help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE"
parser.addini("env", type="linelist", help=help_msg, default=[])
parser.addini("env_files", type="linelist", help="a line separated list of .env files to load", default=[])
parser.addini(
"env_files_skip_if_set",
type="bool",
help="only set .env file variables when not already defined",
default=False,
)
parser.addoption(
"--envfile",
action="store",
dest="envfile",
default=None,
help="path to .env file to load (prefix with + to extend config files, otherwise replaces them)",
)
parser.addoption(
"--pytest-env-verbose",
action="store_true",
dest="pytest_env_verbose",
default=False,
help="print environment variable assignments made by pytest-env",
)
@dataclass
class Entry:
"""Configuration entries."""
key: str
value: str
transform: bool
skip_if_set: bool
unset: bool = False
@pytest.hookimpl(tryfirst=True)
def pytest_load_initial_conftests(
args: list[str], # noqa: ARG001
early_config: pytest.Config,
parser: pytest.Parser, # noqa: ARG001
) -> None:
"""Load environment variables from configuration files."""
verbose = getattr(early_config.known_args_namespace, "pytest_env_verbose", False)
actions: list[tuple[str, str, str, str]] = []
env_files_list: list[str] = []
env_files_skip_if_set: bool | None = None
if toml_config := _find_toml_config(early_config):
env_files_list, _, env_files_skip_if_set = _load_toml_config(toml_config)
if env_files_skip_if_set is None:
env_files_skip_if_set = bool(early_config.getini("env_files_skip_if_set"))
_apply_env_files(
early_config,
env_files_list,
actions if verbose else None,
skip_if_set=env_files_skip_if_set,
)
_apply_entries(early_config, actions if verbose else None)
if verbose and actions:
early_config.stash[_env_actions_key] = _format_actions(actions)
def _apply_env_files(
early_config: pytest.Config,
env_files_list: list[str],
actions: list[tuple[str, str, str, str]] | None,
*,
skip_if_set: bool = False,
) -> None:
preexisting = dict(os.environ) if skip_if_set else {}
for env_file in _load_env_files(early_config, env_files_list):
for key, value in dotenv_values(env_file).items():
if value is not None:
if skip_if_set and key in preexisting:
if actions is not None:
actions.append(("SKIP", key, preexisting[key], str(env_file)))
else:
os.environ[key] = value
if actions is not None:
actions.append(("SET", key, value, str(env_file)))
def _apply_entries(
early_config: pytest.Config,
actions: list[tuple[str, str, str, str]] | None,
) -> None:
source = _config_source(early_config) if actions is not None else ""
for entry in _load_values(early_config):
if entry.unset:
os.environ.pop(entry.key, None)
if actions is not None:
actions.append(("UNSET", entry.key, "", source))
elif entry.skip_if_set and entry.key in os.environ:
if actions is not None:
actions.append(("SKIP", entry.key, os.environ[entry.key], source))
else:
final = entry.value.format(**os.environ) if entry.transform else entry.value
os.environ[entry.key] = final
if actions is not None:
actions.append(("SET", entry.key, final, source))
def pytest_report_header(config: pytest.Config) -> list[str] | None:
"""Display environment variable assignments in test session header."""
if _env_actions_key in config.stash:
return config.stash[_env_actions_key]
return None
def _format_actions(actions: list[tuple[str, str, str, str]]) -> list[str]:
lines = ["pytest-env:"]
for action, key, value, source in actions:
if action == "UNSET":
lines.append(f" {action:<5} {key} (from {source})")
else:
lines.append(f" {action:<5} {key}={value} (from {source})")
return lines
def _find_toml_config(early_config: pytest.Config) -> Path | None:
"""Find TOML config file by checking inipath first, then walking up the tree."""
if (
early_config.inipath
and early_config.inipath.suffix == ".toml"
and early_config.inipath.name in {"pytest.toml", ".pytest.toml", "pyproject.toml"}
):
return early_config.inipath
start_path = early_config.inipath.parent if early_config.inipath is not None else early_config.rootpath
for current_path in [start_path, *start_path.parents]:
for toml_name in ("pytest.toml", ".pytest.toml", "pyproject.toml"):
toml_file = current_path / toml_name
if toml_file.exists():
return toml_file
return None
def _config_source(early_config: pytest.Config) -> str:
"""Describe the configuration source for verbose output."""
if toml_path := _find_toml_config(early_config):
_, entries, _ = _load_toml_config(toml_path)
if entries:
return str(toml_path)
if early_config.inipath:
return str(early_config.inipath)
return "config" # pragma: no cover
def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry], bool | None]:
"""Load env_files and entries from TOML config file."""
with config_path.open("rb") as file_handler:
config = tomllib.load(file_handler)
if config_path.name == "pyproject.toml":
config = config.get("tool", {})
pytest_env_config = config.get("pytest_env", {})
if not pytest_env_config:
return [], [], None
raw_env_files = pytest_env_config.get("env_files")
env_files = [str(f) for f in raw_env_files] if isinstance(raw_env_files, list) else []
raw_skip = pytest_env_config.get("env_files_skip_if_set")
env_files_skip_if_set = raw_skip if isinstance(raw_skip, bool) else None
entries = list(_parse_toml_config(pytest_env_config))
return env_files, entries, env_files_skip_if_set
def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]:
"""Resolve and yield existing env files, with CLI option taking precedence."""
if cli_envfile := getattr(early_config.known_args_namespace, "envfile", None):
if cli_envfile.startswith("+"):
if not (resolved := early_config.rootpath / cli_envfile[1:]).is_file():
msg = f"Environment file not found: {cli_envfile[1:]}"
raise FileNotFoundError(msg)
for env_file_str in env_files or list(early_config.getini("env_files")):
if (config_resolved := early_config.rootpath / env_file_str).is_file():
yield config_resolved
yield resolved
else:
if not (resolved := early_config.rootpath / cli_envfile).is_file():
msg = f"Environment file not found: {cli_envfile}"
raise FileNotFoundError(msg)
yield resolved
return
for env_file_str in env_files or list(early_config.getini("env_files")):
if (resolved := early_config.rootpath / env_file_str).is_file():
yield resolved
def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
"""Load env entries from config, preferring TOML over INI."""
if toml_config := _find_toml_config(early_config):
_, entries, _ = _load_toml_config(toml_config)
if entries:
yield from entries
return
for line in early_config.getini("env"):
# INI lines e.g. D:R:NAME=VAL has two flags (R and D), NAME key, and VAL value
parts = line.partition("=")
ini_key_parts = parts[0].split(":")
flags = {k.strip().upper() for k in ini_key_parts[:-1]}
# R: is a way to designate whether to use raw value -> perform no transformation of the value
transform = "R" not in flags
# D: is a way to mark the value to be set only if it does not exist yet
skip_if_set = "D" in flags
# U: is a way to unset (remove) an environment variable
unset = "U" in flags
key = ini_key_parts[-1].strip()
value = parts[2].strip()
yield Entry(key, value, transform, skip_if_set, unset=unset)
def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]:
for key, entry in config.items():
if key == "env_files" and isinstance(entry, list):
continue
if key == "env_files_skip_if_set" and isinstance(entry, bool):
continue
if isinstance(entry, dict):
unset = bool(entry.get("unset"))
value = str(entry.get("value", "")) if not unset else ""
transform, skip_if_set = bool(entry.get("transform")), bool(entry.get("skip_if_set"))
else:
value, transform, skip_if_set, unset = str(entry), False, False, False
yield Entry(key, value, transform, skip_if_set, unset=unset)