diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04d69b1..396d99b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: diff --git a/README.md b/README.md index c9aa52b..85dea1e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index b136d19..4a8ec77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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 @@ -99,6 +102,6 @@ paths.source = [ ] [tool.mypy] -python_version = "3.10" +python_version = "3.11" show_error_codes = true strict = true diff --git a/src/pytest_env/plugin.py b/src/pytest_env/plugin.py index be7a5e5..f242cef 100644 --- a/src/pytest_env/plugin.py +++ b/src/pytest_env/plugin.py @@ -2,9 +2,18 @@ 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.""" @@ -12,6 +21,14 @@ def pytest_addoption(parser: pytest.Parser) -> None: parser.addini("env", type="linelist", help=help_msg, default=[]) +@dataclass +class Entry: + key: str + value: str + transform: bool + skip_if_set: bool + + @pytest.hookimpl(tryfirst=True) def pytest_load_initial_conftests( args: list[str], # noqa: ARG001 @@ -19,19 +36,41 @@ def pytest_load_initial_conftests( 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) diff --git a/tests/test_env.py b/tests/test_env.py index a409b60..d9b32b6 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -92,7 +92,7 @@ ), ], ) -def test_env( +def test_env_via_pytest( testdir: pytest.Testdir, env: dict[str, str], ini: str, @@ -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( + 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)", + "", + ] diff --git a/tox.ini b/tox.ini index e743352..a75a872 100644 --- a/tox.ini +++ b/tox.ini @@ -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 = @@ -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 =