Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add native toml support #59

Merged
merged 1 commit into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ repos:
rev: "1.2.0"
hooks:
- id: pyproject-fmt
additional_dependencies: ["tox>=4.10"]
additional_dependencies: ["tox>=4.11.3"]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
hooks:
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ pip install pytest-env

## Usage

### Native form in `pyproject.toml`

```toml
[tool.pytest_env]
HOME = "~/tmp"
RUN_ENV = 1
TRANSFORMED = {value = "{USER}/alpha", transform: true}
SKIP_IF_SET = {value = "on", skip_if_set: true}
```

The `tool.pytest_env` tables keys are the environment variables keys to set. The right hands-ide of the assigment:

- if an inline table you can set options via the `transform` or `skip_if_set` keys, while the `value` key holds the
value to set (or transform before setting). For transformation the variables you can use is other environment
variable,
- otherwise the value to set for the environment variable to set (casted to a string).

### Via pytest configurations

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

Expand Down
13 changes: 8 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ dynamic = [
"version",
]
dependencies = [
"pytest>=7.4",
"pytest>=7.4.2",
'tomli>=2.0.1; python_version < "3.11"',
]
optional-dependencies.test = [
"coverage>=7.3",
"pytest-mock>=3.11.1",
"covdefaults>=2.3",
"coverage>=7.3.2",
"pytest-mock>=3.12",
]
urls.Homepage = "https://github.com/pytest-dev/pytest-env"
urls.Source = "https://github.com/pytest-dev/pytest-env"
Expand Down Expand Up @@ -85,7 +87,8 @@ run.source = ["pytest_env", "tests"]
run.dynamic_context = "test_function"
run.branch = true
run.parallel = true
report.fail_under = 92
run.plugins = ["covdefaults"]
report.fail_under = 100
report.show_missing = true
html.show_contexts = true
html.skip_covered = false
Expand All @@ -99,6 +102,6 @@ paths.source = [
]

[tool.mypy]
python_version = "3.10"
python_version = "3.11"
show_error_codes = true
strict = true
69 changes: 55 additions & 14 deletions src/pytest_env/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,77 @@
from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from itertools import chain
from typing import Iterator

import pytest

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=[])


@dataclass
class Entry:
"""Configuration entries."""

key: str
value: str
transform: bool
skip_if_set: bool


@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."""
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
key = ini_key_parts[-1].strip()
value = parts[2].strip()

if skip_if_set and key in os.environ:
for entry in _load_values(early_config):
if entry.skip_if_set and entry.key in os.environ:
continue
# transformation -> replace environment variables, e.g. TEST_DIR={USER}/repo_test_dir.
os.environ[key] = value.format(**os.environ) if transform else value
os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value


def _load_values(early_config: pytest.Config) -> Iterator[Entry]:
has_toml_conf = False
for path in chain.from_iterable([[early_config.rootpath], early_config.rootpath.parents]):
toml_file = path / "pyproject.toml"
if toml_file.exists():
with toml_file.open("rb") as file_handler:
config = tomllib.load(file_handler)
if "tool" in config and "pytest_env" in config["tool"]:
has_toml_conf = True
for key, entry in config["tool"]["pytest_env"].get("env", {}).items():
if isinstance(entry, dict):
value = str(entry["value"])
transform, skip_if_set = bool(entry.get("transform")), bool(entry.get("skip_if_set"))
else:
value, transform, skip_if_set = str(entry), False, False
yield Entry(key, value, transform, skip_if_set)
break

if not has_toml_conf:
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
key = ini_key_parts[-1].strip()
value = parts[2].strip()
yield Entry(key, value, transform, skip_if_set)
83 changes: 82 additions & 1 deletion tests/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
),
],
)
def test_env(
def test_env_via_pytest(
testdir: pytest.Testdir,
env: dict[str, str],
ini: str,
Expand All @@ -116,3 +116,84 @@ def test_env(
result = testdir.runpytest()

result.assert_outcomes(passed=1)


@pytest.mark.parametrize(
("env", "toml", "ini", "expected_env"),
[
pytest.param(
{},
'[tool.pytest.ini_options]\nenv = ["MAGIC=toml", "MAGIC_2=toml2"]',
"[pytest]\nenv = MAGIC=ini\n MAGIC_2=ini2",
{"MAGIC": "ini", "MAGIC_2": "ini2"},
id="ini over toml ini_options",
),
pytest.param(
{},
'[tool.pytest.ini_options]\nenv = ["MAGIC=toml", "MAGIC_2=toml2"]',
"",
{"MAGIC": "toml", "MAGIC_2": "toml2"},
id="toml via ini_options",
),
pytest.param(
{},
'[tool.pytest_env.env]\nMAGIC = 1\nMAGIC_2 = "toml2"',
"",
{"MAGIC": "1", "MAGIC_2": "toml2"},
id="toml native",
),
pytest.param(
{},
'[tool.pytest_env.env]\nMAGIC = 1\nMAGIC_2 = "toml2"',
"[pytest]\nenv = MAGIC=ini\n MAGIC_2=ini2",
{"MAGIC": "1", "MAGIC_2": "toml2"},
id="toml native over ini",
),
pytest.param(
{},
'[tool.pytest_env.env]\nMAGIC = {value = "toml", "transform"= true, "skip_if_set" = true}',
"",
{"MAGIC": "toml"},
id="toml inline table",
),
],
)
def test_env_via_toml( # noqa: PLR0913
testdir: pytest.Testdir,
env: dict[str, str],
toml: str,
ini: str,
expected_env: dict[str, str],
request: pytest.FixtureRequest,
) -> None:
tmp_dir = Path(str(testdir.tmpdir))
test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower()
Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py")
if ini:
(tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8")
(tmp_dir / "pyproject.toml").write_text(toml, encoding="utf-8")

new_env = {
**env,
"_TEST_ENV": repr(expected_env),
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
"PYTEST_PLUGINS": "pytest_env.plugin",
}

# monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict
with mock.patch.dict(os.environ, new_env, clear=True):
result = testdir.runpytest()

result.assert_outcomes(passed=1)


def test_env_via_toml_bad(testdir: pytest.Testdir) -> None:
toml_file = Path(str(testdir.tmpdir)) / "pyproject.toml"
toml_file.write_text("bad toml", encoding="utf-8")

result = testdir.runpytest()
assert result.ret == 4
assert result.errlines == [
f"ERROR: {toml_file}: Expected '=' after a key in a key/value pair (at line 1, column 5)",
"",
]
4 changes: 2 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ commands =
[testenv:type]
description = run type check on code base
deps =
mypy==1.5.1
mypy==1.6.1
set_env =
{tty:MYPY_FORCE_COLOR = 1}
commands =
Expand All @@ -54,7 +54,7 @@ commands =
description = check that the long description is valid
skip_install = true
deps =
build[virtualenv]>=0.10
build[virtualenv]>=1.0.3
twine>=4.0.2
change_dir = {toxinidir}
commands =
Expand Down