├── src └── pytest_env │ ├── py.typed │ ├── __init__.py │ └── plugin.py ├── .github ├── CODEOWNERS ├── FUNDING.yaml ├── release.yaml ├── dependabot.yml ├── SECURITY.md └── workflows │ ├── release.yaml │ └── check.yaml ├── .gitignore ├── tests ├── conftest.py ├── test_version.py ├── template.py └── test_env.py ├── LICENSE ├── .pre-commit-config.yaml ├── tox.ini ├── README.md └── pyproject.toml /src/pytest_env/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gaborbernat 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | tidelift: "pypi/pytest-env" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | dist/ 4 | .tox/ 5 | /src/pytest_env/version.py 6 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | pytest_plugins = ["pytester"] 4 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot[bot] 5 | - pre-commit-ci[bot] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def test_version() -> None: 5 | import pytest_env # noqa: PLC0415 6 | 7 | assert pytest_env.__version__ is not None 8 | -------------------------------------------------------------------------------- /src/pytest_env/__init__.py: -------------------------------------------------------------------------------- 1 | """Pytest set environments.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .version import __version__ 6 | 7 | __all__ = [ 8 | "__version__", 9 | ] 10 | -------------------------------------------------------------------------------- /tests/template.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | import os 5 | 6 | 7 | def test_env() -> None: 8 | for key, value in ast.literal_eval(os.environ["_TEST_ENV"]).items(): 9 | assert os.environ[key] == value, key 10 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 0.8.0 + | :white_check_mark: | 8 | | < 0.8.0 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift 13 | will coordinate the fix and disclosure. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2010-202x The pytest-env developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.36.0 9 | hooks: 10 | - id: check-github-workflows 11 | args: ["--verbose"] 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | additional_dependencies: ["tomli>=2.2.1"] 17 | - repo: https://github.com/tox-dev/tox-ini-fmt 18 | rev: "1.7.1" 19 | hooks: 20 | - id: tox-ini-fmt 21 | args: ["-p", "fix"] 22 | - repo: https://github.com/tox-dev/pyproject-fmt 23 | rev: "v2.11.1" 24 | hooks: 25 | - id: pyproject-fmt 26 | - repo: https://github.com/astral-sh/ruff-pre-commit 27 | rev: "v0.14.9" 28 | hooks: 29 | - id: ruff-format 30 | - id: ruff 31 | args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] 32 | - repo: https://github.com/rbubley/mirrors-prettier 33 | rev: "v3.7.4" 34 | hooks: 35 | - id: prettier 36 | additional_dependencies: 37 | - prettier@3.6.2 38 | - "@prettier/plugin-xml@3.4.2" 39 | - repo: meta 40 | hooks: 41 | - id: check-hooks-apply 42 | - id: check-useless-excludes 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | with: 15 | fetch-depth: 0 16 | - name: Install the latest version of uv 17 | uses: astral-sh/setup-uv@v7 18 | with: 19 | enable-cache: true 20 | cache-dependency-glob: "pyproject.toml" 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build package 23 | run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v6 26 | with: 27 | name: ${{ env.dists-artifact-name }} 28 | path: dist/* 29 | 30 | release: 31 | needs: 32 | - build 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: release 36 | url: https://pypi.org/project/pytest-env/${{ github.ref_name }} 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v7 42 | with: 43 | name: ${{ env.dists-artifact-name }} 44 | path: dist/ 45 | - name: Publish to PyPI 46 | uses: pypa/gh-action-pypi-publish@v1.13.0 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ["main"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | schedule: 9 | - cron: "0 8 * * *" 10 | 11 | concurrency: 12 | group: check-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | env: 22 | - "3.14" 23 | - "3.13" 24 | - "3.12" 25 | - "3.11" 26 | - "3.10" 27 | - type 28 | - dev 29 | - pkg_meta 30 | steps: 31 | - uses: actions/checkout@v6 32 | with: 33 | fetch-depth: 0 34 | - name: Install the latest version of uv 35 | uses: astral-sh/setup-uv@v7 36 | with: 37 | enable-cache: true 38 | cache-dependency-glob: "pyproject.toml" 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | - name: Install tox 41 | run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv 42 | - name: Install Python 43 | if: startsWith(matrix.env, '3.') && matrix.env != '3.14' 44 | run: uv python install --python-preference only-managed ${{ matrix.env }} 45 | - name: Setup test suite 46 | run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }} 47 | - name: Run test suite 48 | run: tox run --skip-pkg-install -e ${{ matrix.env }} 49 | env: 50 | PYTEST_ADDOPTS: "-vv --durations=20" 51 | DIFF_AGAINST: HEAD 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.30.3 4 | tox-uv>=1.28 5 | env_list = 6 | fix 7 | 3.14 8 | 3.13 9 | 3.12 10 | 3.11 11 | 3.10 12 | type 13 | pkg_meta 14 | skip_missing_interpreters = true 15 | 16 | [testenv] 17 | description = run the tests with pytest 18 | package = wheel 19 | wheel_build_env = .pkg 20 | extras = 21 | testing 22 | pass_env = 23 | DIFF_AGAINST 24 | PYTEST_* 25 | set_env = 26 | COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}} 27 | commands = 28 | coverage erase 29 | coverage run -m pytest {tty:--color=yes} \ 30 | --junitxml {toxworkdir}{/}junit.{envname}.xml \ 31 | {posargs:tests} 32 | coverage combine 33 | coverage report 34 | coverage html -d {envtmpdir}{/}htmlcov 35 | 36 | [testenv:fix] 37 | description = run static analysis and style check using flake8 38 | skip_install = true 39 | deps = 40 | pre-commit-uv>=4.1.5 41 | pass_env = 42 | HOMEPATH 43 | PROGRAMDATA 44 | commands = 45 | pre-commit run --all-files --show-diff-on-failure 46 | 47 | [testenv:type] 48 | description = run type check on code base 49 | deps = 50 | mypy==1.18.2 51 | commands = 52 | mypy --strict src 53 | mypy --strict tests 54 | 55 | [testenv:pkg_meta] 56 | description = check that the long description is valid 57 | skip_install = true 58 | deps = 59 | check-wheel-contents>=0.6.3 60 | twine>=6.2 61 | uv>=0.8.22 62 | commands = 63 | uv build --sdist --wheel --out-dir {env_tmp_dir} . 64 | twine check {env_tmp_dir}{/}* 65 | check-wheel-contents --no-config {env_tmp_dir} 66 | 67 | [testenv:dev] 68 | description = generate a DEV environment 69 | package = editable 70 | extras = 71 | testing 72 | commands = 73 | uv pip tree 74 | python -c 'import sys; print(sys.executable)' 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-env 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/pytest-env?style=flat-square)](https://pypi.org/project/pytest-env/) 4 | [![Supported Python 5 | versions](https://img.shields.io/pypi/pyversions/pytest-env.svg)](https://pypi.org/project/pytest-env/) 6 | [![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) 7 | [![Downloads](https://static.pepy.tech/badge/pytest-env/month)](https://pepy.tech/project/pytest-env) 8 | 9 | This is a `pytest` plugin that enables you to set environment variables in a `pytest.ini` or `pyproject.toml` file. 10 | 11 | ## Installation 12 | 13 | Install with pip: 14 | 15 | ```shell 16 | pip install pytest-env 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Native form in `pyproject.toml` 22 | 23 | ```toml 24 | [tool.pytest_env] 25 | HOME = "~/tmp" 26 | RUN_ENV = 1 27 | TRANSFORMED = {value = "{USER}/alpha", transform = true} 28 | SKIP_IF_SET = {value = "on", skip_if_set = true} 29 | ``` 30 | 31 | The `tool.pytest_env` tables keys are the environment variables keys to set. The right hand side of the assignment: 32 | 33 | - if an inline table you can set options via the `transform` or `skip_if_set` keys, while the `value` key holds the 34 | value to set (or transform before setting). For transformation the variables you can use is other environment 35 | variable, 36 | - otherwise the value to set for the environment variable to set (casted to a string). 37 | 38 | ### Via pytest configurations 39 | 40 | In your pytest.ini file add a key value pair with `env` as the key and the environment variables as a line separated 41 | list of `KEY=VALUE` entries. The defined variables will be added to the environment before any tests are run: 42 | 43 | ```ini 44 | [pytest] 45 | env = 46 | HOME=~/tmp 47 | RUN_ENV=test 48 | ``` 49 | 50 | Or with `pyproject.toml`: 51 | 52 | ```toml 53 | [tool.pytest.ini_options] 54 | env = [ 55 | "HOME=~/tmp", 56 | "RUN_ENV=test", 57 | ] 58 | ``` 59 | 60 | ### Only set if not already set 61 | 62 | You can use `D:` (default) as prefix if you don't want to override existing environment variables: 63 | 64 | ```ini 65 | [pytest] 66 | env = 67 | D:HOME=~/tmp 68 | D:RUN_ENV=test 69 | ``` 70 | 71 | ### Transformation 72 | 73 | You can use existing environment variables using a python-like format, these environment variables will be expended 74 | before setting the environment variable: 75 | 76 | ```ini 77 | [pytest] 78 | env = 79 | RUN_PATH=/run/path/{USER} 80 | ``` 81 | 82 | You can apply the `R:` prefix to keep the raw value and skip this transformation step (can combine with the `D:` flag, 83 | order is not important): 84 | 85 | ```ini 86 | [pytest] 87 | env = 88 | R:RUN_PATH=/run/path/{USER} 89 | R:D:RUN_PATH_IF_NOT_SET=/run/path/{USER} 90 | ``` 91 | -------------------------------------------------------------------------------- /src/pytest_env/plugin.py: -------------------------------------------------------------------------------- 1 | """Adopt environment section in pytest configuration files.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | from dataclasses import dataclass 8 | from itertools import chain 9 | from typing import TYPE_CHECKING 10 | 11 | import pytest 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator 15 | 16 | if sys.version_info >= (3, 11): # pragma: >=3.11 cover 17 | import tomllib 18 | else: # pragma: <3.11 cover 19 | import tomli as tomllib 20 | 21 | 22 | def pytest_addoption(parser: pytest.Parser) -> None: 23 | """Add section to configuration files.""" 24 | help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE" 25 | parser.addini("env", type="linelist", help=help_msg, default=[]) 26 | 27 | 28 | @dataclass 29 | class Entry: 30 | """Configuration entries.""" 31 | 32 | key: str 33 | value: str 34 | transform: bool 35 | skip_if_set: bool 36 | 37 | 38 | @pytest.hookimpl(tryfirst=True) 39 | def pytest_load_initial_conftests( 40 | args: list[str], # noqa: ARG001 41 | early_config: pytest.Config, 42 | parser: pytest.Parser, # noqa: ARG001 43 | ) -> None: 44 | """Load environment variables from configuration files.""" 45 | for entry in _load_values(early_config): 46 | if entry.skip_if_set and entry.key in os.environ: 47 | continue 48 | # transformation -> replace environment variables, e.g. TEST_DIR={USER}/repo_test_dir. 49 | os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value 50 | 51 | 52 | def _load_values(early_config: pytest.Config) -> Iterator[Entry]: 53 | has_toml_conf = False 54 | for path in chain.from_iterable([[early_config.rootpath], early_config.rootpath.parents]): # noqa: PLR1702 55 | toml_file = path / "pyproject.toml" 56 | if toml_file.exists(): 57 | with toml_file.open("rb") as file_handler: 58 | config = tomllib.load(file_handler) 59 | if "tool" in config and "pytest_env" in config["tool"]: 60 | has_toml_conf = True 61 | for key, entry in config["tool"]["pytest_env"].items(): 62 | if isinstance(entry, dict): 63 | value = str(entry["value"]) 64 | transform, skip_if_set = bool(entry.get("transform")), bool(entry.get("skip_if_set")) 65 | else: 66 | value, transform, skip_if_set = str(entry), False, False 67 | yield Entry(key, value, transform, skip_if_set) 68 | break 69 | 70 | if not has_toml_conf: 71 | for line in early_config.getini("env"): 72 | # INI lines e.g. D:R:NAME=VAL has two flags (R and D), NAME key, and VAL value 73 | parts = line.partition("=") 74 | ini_key_parts = parts[0].split(":") 75 | flags = {k.strip().upper() for k in ini_key_parts[:-1]} 76 | # R: is a way to designate whether to use raw value -> perform no transformation of the value 77 | transform = "R" not in flags 78 | # D: is a way to mark the value to be set only if it does not exist yet 79 | skip_if_set = "D" in flags 80 | key = ini_key_parts[-1].strip() 81 | value = parts[2].strip() 82 | yield Entry(key, value, transform, skip_if_set) 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.5", 5 | "hatchling>=1.27", 6 | ] 7 | 8 | [project] 9 | name = "pytest-env" 10 | description = "pytest plugin that allows you to add environment variables." 11 | readme = "README.md" 12 | keywords = [ 13 | "env", 14 | "pytest", 15 | ] 16 | license.file = "LICENSE" 17 | maintainers = [ 18 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 19 | ] 20 | requires-python = ">=3.10" 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: Implementation :: CPython", 33 | "Topic :: Software Development :: Libraries :: Python Modules", 34 | ] 35 | dynamic = [ 36 | "version", 37 | ] 38 | dependencies = [ 39 | "pytest>=8.4.2", 40 | "tomli>=2.2.1; python_version<'3.11'", 41 | ] 42 | optional-dependencies.testing = [ 43 | "covdefaults>=2.3", 44 | "coverage>=7.10.7", 45 | "pytest-mock>=3.15.1", 46 | ] 47 | urls.Homepage = "https://github.com/pytest-dev/pytest-env" 48 | urls.Source = "https://github.com/pytest-dev/pytest-env" 49 | urls.Tracker = "https://github.com/pytest-dev/pytest-env/issues" 50 | entry-points.pytest11.env = "pytest_env.plugin" 51 | 52 | [tool.hatch] 53 | build.hooks.vcs.version-file = "src/pytest_env/version.py" 54 | version.source = "vcs" 55 | 56 | [tool.ruff] 57 | line-length = 120 58 | format.preview = true 59 | format.docstring-code-line-length = 100 60 | format.docstring-code-format = true 61 | lint.select = [ 62 | "ALL", 63 | ] 64 | lint.ignore = [ 65 | "ANN101", # no type annotation for self 66 | "COM812", # Conflict with formatter 67 | "CPY", # No copyright statements 68 | "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible 69 | "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible 70 | "ISC001", # Conflict with formatter 71 | "S104", # Possible binding to all interface 72 | ] 73 | lint.per-file-ignores."tests/**/*.py" = [ 74 | "D", # don"t care about documentation in tests 75 | "FBT", # don"t care about booleans as positional arguments in tests 76 | "INP001", # no implicit namespace 77 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable 78 | "S101", # asserts allowed in tests... 79 | "S603", # `subprocess` call: check for execution of untrusted input 80 | ] 81 | lint.isort = { known-first-party = [ 82 | "pytest_env", 83 | ], required-imports = [ 84 | "from __future__ import annotations", 85 | ] } 86 | lint.preview = true 87 | 88 | [tool.codespell] 89 | builtin = "clear,usage,en-GB_to_en-US" 90 | write-changes = true 91 | count = true 92 | 93 | [tool.pyproject-fmt] 94 | max_supported_python = "3.13" 95 | 96 | [tool.coverage] 97 | run.source = [ 98 | "pytest_env", 99 | "tests", 100 | ] 101 | run.dynamic_context = "test_function" 102 | run.branch = true 103 | run.parallel = true 104 | run.plugins = [ 105 | "covdefaults", 106 | ] 107 | report.fail_under = 100 108 | report.show_missing = true 109 | html.show_contexts = true 110 | html.skip_covered = false 111 | paths.source = [ 112 | "src", 113 | ".tox*/*/lib/python*/site-packages", 114 | ".tox*/pypy*/site-packages", 115 | ".tox*\\*\\Lib\\site-packages", 116 | "*/src", 117 | "*\\src", 118 | ] 119 | 120 | [tool.mypy] 121 | python_version = "3.11" 122 | show_error_codes = true 123 | strict = true 124 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | from pathlib import Path 6 | from unittest import mock 7 | 8 | import pytest 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("env", "ini", "expected_env"), 13 | [ 14 | pytest.param( 15 | {}, 16 | "[pytest]\nenv = MAGIC=alpha", 17 | {"MAGIC": "alpha"}, 18 | id="new key - add to env", 19 | ), 20 | pytest.param( 21 | {}, 22 | "[pytest]\nenv = MAGIC=alpha\n SORCERY=beta", 23 | {"MAGIC": "alpha", "SORCERY": "beta"}, 24 | id="two new keys - add to env", 25 | ), 26 | pytest.param( 27 | # This test also tests for non-interference of env variables between this test and tests above 28 | {}, 29 | "[pytest]\nenv = d:MAGIC=beta", 30 | {"MAGIC": "beta"}, 31 | id="D flag - add to env", 32 | ), 33 | pytest.param( 34 | {"MAGIC": "alpha"}, 35 | "[pytest]\nenv = MAGIC=beta", 36 | {"MAGIC": "beta"}, 37 | id="key exists in env - overwrite", 38 | ), 39 | pytest.param( 40 | {"MAGIC": "alpha"}, 41 | "[pytest]\nenv = D:MAGIC=beta", 42 | {"MAGIC": "alpha"}, 43 | id="D exists - original val kept", 44 | ), 45 | pytest.param( 46 | {"PLANET": "world"}, 47 | "[pytest]\nenv = MAGIC=hello_{PLANET}", 48 | {"MAGIC": "hello_world"}, 49 | id="curly exist - interpolate var", 50 | ), 51 | pytest.param( 52 | {"PLANET": "world"}, 53 | "[pytest]\nenv = R:MAGIC=hello_{PLANET}", 54 | {"MAGIC": "hello_{PLANET}"}, 55 | id="R exists - not interpolate var", 56 | ), 57 | pytest.param( 58 | {"MAGIC": "a"}, 59 | "[pytest]\nenv = R:MAGIC={MAGIC}b\n D:MAGIC={MAGIC}c\n MAGIC={MAGIC}d", 60 | {"MAGIC": "{MAGIC}bd"}, 61 | id="incremental interpolation", 62 | ), 63 | pytest.param( 64 | {"PLANET": "world"}, 65 | "[pytest]\nenv = D:R:RESULT=hello_{PLANET}", 66 | {"RESULT": "hello_{PLANET}"}, 67 | id="two flags", 68 | ), 69 | pytest.param( 70 | {"PLANET": "world"}, 71 | "[pytest]\nenv = R:D:RESULT=hello_{PLANET}", 72 | {"RESULT": "hello_{PLANET}"}, 73 | id="two flags - reversed", 74 | ), 75 | pytest.param( 76 | {"PLANET": "world"}, 77 | "[pytest]\nenv = d:r:RESULT=hello_{PLANET}", 78 | {"RESULT": "hello_{PLANET}"}, 79 | id="lowercase flags", 80 | ), 81 | pytest.param( 82 | {"PLANET": "world"}, 83 | "[pytest]\nenv = D : R : RESULT = hello_{PLANET}", 84 | {"RESULT": "hello_{PLANET}"}, 85 | id="whitespace is ignored", 86 | ), 87 | pytest.param( 88 | {"MAGIC": "zero"}, 89 | "", 90 | {"MAGIC": "zero"}, 91 | id="empty ini works", 92 | ), 93 | ], 94 | ) 95 | def test_env_via_pytest( 96 | testdir: pytest.Testdir, 97 | env: dict[str, str], 98 | ini: str, 99 | expected_env: dict[str, str], 100 | request: pytest.FixtureRequest, 101 | ) -> None: 102 | tmp_dir = Path(str(testdir.tmpdir)) 103 | test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower() 104 | Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py") 105 | (tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8") 106 | 107 | new_env = { 108 | **env, 109 | "_TEST_ENV": repr(expected_env), 110 | "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", 111 | "PYTEST_PLUGINS": "pytest_env.plugin", 112 | } 113 | 114 | # monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict 115 | with mock.patch.dict(os.environ, new_env, clear=True): 116 | result = testdir.runpytest() 117 | 118 | result.assert_outcomes(passed=1) 119 | 120 | 121 | @pytest.mark.parametrize( 122 | ("env", "toml", "ini", "expected_env"), 123 | [ 124 | pytest.param( 125 | {}, 126 | '[tool.pytest.ini_options]\nenv = ["MAGIC=toml", "MAGIC_2=toml2"]', 127 | "[pytest]\nenv = MAGIC=ini\n MAGIC_2=ini2", 128 | {"MAGIC": "ini", "MAGIC_2": "ini2"}, 129 | id="ini over toml ini_options", 130 | ), 131 | pytest.param( 132 | {}, 133 | '[tool.pytest.ini_options]\nenv = ["MAGIC=toml", "MAGIC_2=toml2"]', 134 | "", 135 | {"MAGIC": "toml", "MAGIC_2": "toml2"}, 136 | id="toml via ini_options", 137 | ), 138 | pytest.param( 139 | {}, 140 | '[tool.pytest_env]\nMAGIC = 1\nMAGIC_2 = "toml2"', 141 | "", 142 | {"MAGIC": "1", "MAGIC_2": "toml2"}, 143 | id="toml native", 144 | ), 145 | pytest.param( 146 | {}, 147 | '[tool.pytest_env]\nMAGIC = 1\nMAGIC_2 = "toml2"', 148 | "[pytest]\nenv = MAGIC=ini\n MAGIC_2=ini2", 149 | {"MAGIC": "1", "MAGIC_2": "toml2"}, 150 | id="toml native over ini", 151 | ), 152 | pytest.param( 153 | {}, 154 | '[tool.pytest_env]\nMAGIC = {value = "toml", "transform"= true, "skip_if_set" = true}', 155 | "", 156 | {"MAGIC": "toml"}, 157 | id="toml inline table", 158 | ), 159 | ], 160 | ) 161 | def test_env_via_toml( # noqa: PLR0913, PLR0917 162 | testdir: pytest.Testdir, 163 | env: dict[str, str], 164 | toml: str, 165 | ini: str, 166 | expected_env: dict[str, str], 167 | request: pytest.FixtureRequest, 168 | ) -> None: 169 | tmp_dir = Path(str(testdir.tmpdir)) 170 | test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower() 171 | Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py") 172 | if ini: 173 | (tmp_dir / "pytest.ini").write_text(ini, encoding="utf-8") 174 | (tmp_dir / "pyproject.toml").write_text(toml, encoding="utf-8") 175 | 176 | new_env = { 177 | **env, 178 | "_TEST_ENV": repr(expected_env), 179 | "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", 180 | "PYTEST_PLUGINS": "pytest_env.plugin", 181 | } 182 | 183 | # monkeypatch persists env variables across parametrized tests, therefore using mock.patch.dict 184 | with mock.patch.dict(os.environ, new_env, clear=True): 185 | result = testdir.runpytest() 186 | 187 | result.assert_outcomes(passed=1) 188 | 189 | 190 | def test_env_via_toml_bad(testdir: pytest.Testdir) -> None: 191 | toml_file = Path(str(testdir.tmpdir)) / "pyproject.toml" 192 | toml_file.write_text("bad toml", encoding="utf-8") 193 | 194 | result = testdir.runpytest() 195 | assert result.ret == 4 196 | assert result.errlines == [ 197 | f"ERROR: {toml_file}: Expected '=' after a key in a key/value pair (at line 1, column 5)", 198 | "", 199 | ] 200 | --------------------------------------------------------------------------------