Skip to content

Commit

Permalink
Add native toml support (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
gaborbernat authored Oct 24, 2023
1 parent e67abc4 commit 81a2692
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 23 deletions.
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

0 comments on commit 81a2692

Please sign in to comment.