From 9a304624f4b63c304ee7cd979e59afa61c17afe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 17 Feb 2026 07:26:03 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20add=20--envfile=20opti?= =?UTF-8?q?on=20for=20runtime=20environment=20switching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users needed a way to switch between different environment configurations without modifying config files. This is particularly useful when testing against different environments (dev, staging, prod) or when developers need local overrides without changing version-controlled configuration. Implemented a --envfile CLI argument with two modes. Override mode (--envfile PATH) replaces all configured env_files, while extend mode (--envfile +PATH) adds to them. The CLI file always loads last in extend mode, ensuring its variables take precedence. Unlike config-based env_files which silently skip missing files, CLI files must exist to fail fast on typos or incorrect paths. Restructured README following Diátaxis framework to separate tutorial content (quick start), how-to guides (task-focused), reference (complete API), and explanation (conceptual understanding). This makes the documentation more navigable and helps users find what they need based on their goal. Signed-off-by: Bernát Gábor --- .pre-commit-config.yaml | 7 +- README.md | 323 +++++++++++++++++++++++++-------------- pyproject.toml | 1 + src/pytest_env/plugin.py | 29 +++- tests/test_env.py | 96 ++++++++++++ tox.ini | 73 --------- tox.toml | 75 +++++++++ 7 files changed, 406 insertions(+), 198 deletions(-) delete mode 100644 tox.ini create mode 100644 tox.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5dab88..eaf6302 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,11 +14,10 @@ repos: hooks: - id: codespell additional_dependencies: ["tomli>=2.4"] - - repo: https://github.com/tox-dev/tox-ini-fmt - rev: "1.7.1" + - repo: https://github.com/tox-dev/tox-toml-fmt + rev: "v1.6.0" hooks: - - id: tox-ini-fmt - args: ["-p", "fix"] + - id: tox-toml-fmt - repo: https://github.com/tox-dev/pyproject-fmt rev: "v2.16.0" hooks: diff --git a/README.md b/README.md index 3336a5a..593749d 100644 --- a/README.md +++ b/README.md @@ -14,46 +14,140 @@ configuration files. It can also load variables from `.env` files. pip install pytest-env ``` -## Usage +## Quick start -### TOML configuration (native form) +Add environment variables to your `pyproject.toml`: -Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` / -`.pytest.toml`: +```toml +[tool.pytest_env] +DATABASE_URL = "postgresql://localhost/test_db" +DEBUG = "true" +``` + +Run your tests. The environment variables are now available: + +```python +import os + + +def test_database_connection(): + assert os.environ["DATABASE_URL"] == "postgresql://localhost/test_db" + assert os.environ["DEBUG"] == "true" +``` + +## How-to guides + +### Set different environments for test suites + +Create a subdirectory config to override parent settings: + +``` +project/ +├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db" +└── tests_integration/ + ├── pytest.toml # [pytest_env] DB_HOST = "test-db" + └── test_api.py +``` + +Running `pytest tests_integration/` uses the subdirectory configuration. + +### Switch environments at runtime + +Use the `--envfile` CLI option to override or extend your configuration: + +```shell +# Override all configured env files with a different one. +pytest --envfile .env.local + +# Add an additional env file to those already configured. +pytest --envfile +.env.override +``` + +Override mode loads only the specified file. Extend mode (prefix with `+`) loads configuration files first, then the CLI +file. Variables in the CLI file take precedence. + +### Load variables from `.env` files + +Specify `.env` files in your configuration: ```toml -# pyproject.toml [tool.pytest_env] -HOME = "~/tmp" -RUN_ENV = 1 -TRANSFORMED = { value = "{USER}/alpha", transform = true } -SKIP_IF_SET = { value = "on", skip_if_set = true } -DATABASE_URL = { unset = true } +env_files = [".env", ".env.test"] +``` + +Create your `.env` file: + +```shell +DATABASE_URL=postgres://localhost/mydb +SECRET_KEY='my-secret-key' +DEBUG="true" +``` + +Files are loaded before inline variables, so inline configuration takes precedence. + +### Expand variables using other environment variables + +Reference existing environment variables in values: + +```toml +[tool.pytest_env] +RUN_PATH = { value = "/run/path/{USER}", transform = true } +``` + +The `{USER}` placeholder expands to the current user's name. + +### Set conditional defaults + +Only set a variable if it does not already exist: + +```toml +[tool.pytest_env] +HOME = { value = "~/tmp", skip_if_set = true } ``` +This leaves `HOME` unchanged if already set, otherwise sets it to `~/tmp`. + +### Remove variables from the environment + +Unset a variable completely (different from setting to empty string): + ```toml -# pytest.toml or .pytest.toml -[pytest_env] -HOME = "~/tmp" -RUN_ENV = 1 -TRANSFORMED = { value = "{USER}/alpha", transform = true } -SKIP_IF_SET = { value = "on", skip_if_set = true } +[tool.pytest_env] DATABASE_URL = { unset = true } ``` -Each key is the environment variable name. The value is either a plain value (cast to string) or an inline table with -the following keys: +## Reference + +### TOML configuration format + +Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` or +`.pytest.toml`: + +```toml +# pyproject.toml +[tool.pytest_env] +SIMPLE_VAR = "value" +NUMBER_VAR = 42 +EXPANDED = { value = "{HOME}/path", transform = true } +CONDITIONAL = { value = "default", skip_if_set = true } +REMOVED = { unset = true } +``` + +Each key is the environment variable name. Values can be: -| Key | Type | Description | -| ------------- | ------ | --------------------------------------------------------------------------- | -| `value` | string | The value to set | -| `transform` | bool | Expand `{VAR}` references in the value using existing environment variables | -| `skip_if_set` | bool | Only set the variable if it is not already defined | -| `unset` | bool | Remove the variable from the environment (ignores `value`) | +- **Plain values**: Cast to string and set directly. +- **Inline tables**: Objects with the following keys: -### INI configuration +| Key | Type | Description | +| ------------- | ------ | ---------------------------------------------------------------------------- | +| `value` | string | The value to set. | +| `transform` | bool | Expand `{VAR}` references in the value using existing environment variables. | +| `skip_if_set` | bool | Only set the variable if it is not already defined. | +| `unset` | bool | Remove the variable from the environment (ignores `value`). | -Define environment variables as a line-separated list of `KEY=VALUE` entries under the `env` key: +### INI configuration format + +Define environment variables as line-separated `KEY=VALUE` entries: ```ini # pytest.ini @@ -61,10 +155,13 @@ Define environment variables as a line-separated list of `KEY=VALUE` entries und env = HOME=~/tmp RUN_ENV=test + D:CONDITIONAL=value + R:RAW_VALUE={USER} + U:REMOVED_VAR ``` ```toml -# pyproject.toml +# pyproject.toml (INI-style) [tool.pytest] env = [ "HOME=~/tmp", @@ -74,133 +171,125 @@ env = [ Prefix flags modify behavior. Flags are case-insensitive and can be combined in any order (e.g., `R:D:KEY=VALUE`): -| Flag | Description | -| ---- | ------------------------------------------------------------------ | -| `D:` | Default — only set if the variable is not already defined | -| `R:` | Raw — skip `{VAR}` expansion (INI expands by default, unlike TOML) | -| `U:` | Unset — remove the variable from the environment entirely | - -### Precedence +| Flag | Description | +| ---- | ------------------------------------------------------------------- | +| `D:` | Default — only set if the variable is not already defined. | +| `R:` | Raw — skip `{VAR}` expansion (INI expands by default, unlike TOML). | +| `U:` | Unset — remove the variable from the environment entirely. | -When multiple configuration sources are present, the native TOML form takes precedence over the INI form. Within the -TOML form, files are checked in this order: `pytest.toml`, `.pytest.toml`, `pyproject.toml`. The first file containing a -`pytest_env` section wins. +**Note**: In INI format, variable expansion is enabled by default. In TOML format, it requires `transform = true`. -### Configuration file discovery +### `.env` file format -The plugin walks the directory tree starting from the directory containing the configuration file pytest resolved -(`inipath`). For each directory it checks `pytest.toml`, `.pytest.toml`, and `pyproject.toml` in order, stopping at the -first file with a `pytest_env` section. This means a subdirectory config takes precedence over a parent config: - -``` -project/ -├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db" -└── tests_integration/ - ├── pytest.toml # [pytest_env] DB_HOST = "test-db" - └── test_api.py -``` - -Running `pytest tests_integration/` uses `DB_HOST = "test-db"` from the subdirectory. - -If no TOML file with a `pytest_env` section is found, the plugin falls back to the INI-style `env` key. - -### Loading `.env` files - -Use `env_files` to load variables from `.env` files. Files are loaded before inline `env` entries, so inline config -takes precedence. Missing files are silently skipped. Paths are relative to the project root. +Specify `.env` files using the `env_files` configuration option: ```toml -# pyproject.toml [tool.pytest_env] env_files = [".env", ".env.test"] -API_KEY = "override_value" -``` - -```toml -# pytest.toml or .pytest.toml -[pytest_env] -env_files = [".env"] ``` ```ini -# pytest.ini [pytest] env_files = .env .env.test ``` -Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv), supporting `KEY=VALUE` lines, `#` -comments, `export` prefix, quoted values (with escape sequences in double quotes), and `${VAR:-default}` expansion: +Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv) and support: + +- `KEY=VALUE` lines +- `#` comments +- `export` prefix +- Quoted values with escape sequences in double quotes +- `${VAR:-default}` expansion + +Example `.env` file: ```shell -# .env DATABASE_URL=postgres://localhost/mydb export SECRET_KEY='my-secret-key' DEBUG="true" MESSAGE="hello\nworld" +API_KEY=${FALLBACK_KEY:-default_key} ``` -### Examples +Missing `.env` files are silently skipped. Paths are resolved relative to the project root. -**Expanding environment variables** — reference existing variables using `{VAR}` syntax: +### CLI option: `--envfile` -```toml -[pytest_env] -RUN_PATH = { value = "/run/path/{USER}", transform = true } -``` +Override or extend configuration-based `env_files` at runtime: -```ini -[pytest] -env = - RUN_PATH=/run/path/{USER} +```shell +pytest --envfile PATH # Override mode +pytest --envfile +PATH # Extend mode ``` -In TOML, expansion requires `transform = true`. In INI, expansion is the default; use the `R:` flag to disable it. +**Override mode** (`--envfile PATH`): Loads only the specified file, ignoring all `env_files` from configuration. -**Keeping raw values** — prevent `{VAR}` expansion: +**Extend mode** (`--envfile +PATH`): Loads configuration files first in their normal order, then loads the CLI file. +Variables from the CLI file override those from configuration files. -```toml -[pytest_env] -PATTERN = { value = "/run/path/{USER}" } -``` +Unlike configuration-based `env_files`, CLI-specified files must exist. Missing files raise `FileNotFoundError`. Paths +are resolved relative to the project root. -```ini -[pytest] -env = - R:PATTERN=/run/path/{USER} -``` +## Explanation -**Conditional defaults** — only set when not already defined: +### Configuration precedence -```toml -[pytest_env] -HOME = { value = "~/tmp", skip_if_set = true } -``` +When multiple configuration sources define the same variable, the following precedence rules apply (highest to lowest): -```ini -[pytest] -env = - D:HOME=~/tmp -``` +1. Inline variables in configuration files (TOML or INI format) +1. Variables from `.env` files loaded via `env_files` +1. Variables already present in the environment (unless `skip_if_set = false` or no `D:` flag) -**Unsetting variables** — completely remove a variable from `os.environ` (not the same as setting to empty string): +When using `--envfile`, CLI files take precedence over configuration-based `env_files`, but inline variables still win. -```toml -[pytest_env] -DATABASE_URL = { unset = true } -``` +### Configuration format precedence -```ini -[pytest] -env = - U:DATABASE_URL -``` +When multiple configuration formats are present: -**Combining flags** — flags can be combined in any order: +1. TOML native format (`[pytest_env]` or `[tool.pytest_env]`) takes precedence over INI format. +1. Among TOML files, the first file with a `pytest_env` section is used, checked in order: `pytest.toml`, + `.pytest.toml`, `pyproject.toml`. +1. If no TOML file contains `pytest_env`, the plugin falls back to INI-style `env` configuration. -```ini -[pytest] -env = - R:D:TEMPLATE=/path/{placeholder} -``` +### File discovery + +The plugin walks up the directory tree starting from pytest's resolved configuration directory. For each directory, it +checks `pytest.toml`, `.pytest.toml`, and `pyproject.toml` in order, stopping at the first file containing a +`pytest_env` section. + +This means subdirectory configurations take precedence over parent configurations, allowing you to have different +settings for integration tests versus unit tests. + +### When to use TOML vs INI format + +Use the **TOML native format** (`[pytest_env]`) when: + +- You need fine-grained control over expansion and conditional setting. +- Your configuration is complex with multiple inline tables. +- You prefer explicit `transform = true` for variable expansion. + +Use the **INI format** (`env` key) when: + +- You want simple `KEY=VALUE` pairs with minimal syntax. +- You prefer expansion by default (add `R:` to disable). +- You are migrating from an existing INI-based setup. + +Both formats are fully supported and can coexist (TOML takes precedence if both are present). + +### When to use `.env` files vs inline configuration + +Use **`.env` files** when: + +- You have many environment variables that would clutter your config file. +- You want to share environment configuration with other tools (e.g., Docker, shell scripts). +- You need different `.env` files for different environments (dev, staging, prod). + +Use **inline configuration** when: + +- You have a small number of test-specific variables. +- You want variables to be version-controlled alongside test configuration. +- You need features like `transform`, `skip_if_set`, or `unset` that `.env` files do not support. + +You can combine both approaches. Inline variables always take precedence over `.env` files. diff --git a/pyproject.toml b/pyproject.toml index 6454829..bea77bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ lint.ignore = [ "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible "DOC201", # `return` is not documented in docstring (we prefer minimal docs) "DOC402", # `yield` is not documented in docstring (we prefer minimal docs) + "DOC501", # `raises` is not documented in docstring (we prefer minimal docs) "ISC001", # Conflict with formatter "S104", # Possible binding to all interface ] diff --git a/src/pytest_env/plugin.py b/src/pytest_env/plugin.py index e6a06bd..baa67e8 100644 --- a/src/pytest_env/plugin.py +++ b/src/pytest_env/plugin.py @@ -25,6 +25,13 @@ def pytest_addoption(parser: pytest.Parser) -> None: 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.addoption( + "--envfile", + action="store", + dest="envfile", + default=None, + help="path to .env file to load (prefix with + to extend config files, otherwise replaces them)", + ) @dataclass @@ -100,10 +107,24 @@ def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]: def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]: - """Resolve and yield existing env files.""" - if not env_files: - env_files = list(early_config.getini("env_files")) - for env_file_str in env_files: + """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 diff --git a/tests/test_env.py b/tests/test_env.py index 7f78a5c..afcdfbc 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -548,3 +548,99 @@ def test_env_via_pyproject_toml_bad(pytester: pytest.Pytester, toml_name: str) - f"ERROR: {toml_file}: Expected '=' after a key in a key/value pair (at line 1, column 5)", "", ] + + +@pytest.mark.parametrize( + ("env_file_content", "cli_file_content", "config", "cli_arg", "expected_env"), + [ + pytest.param( + "MAGIC=from_env", + "MAGIC=from_cli", + '[tool.pytest_env]\nenv_files = [".env"]', + "cli.env", + {"MAGIC": "from_cli"}, + id="override mode", + ), + pytest.param( + "MAGIC=from_env\nEXTRA=config", + "MAGIC=from_cli", + '[tool.pytest_env]\nenv_files = [".env"]', + "+cli.env", + {"MAGIC": "from_cli", "EXTRA": "config"}, + id="extend mode", + ), + pytest.param( + None, + "MAGIC=from_cli", + None, + "cli.env", + {"MAGIC": "from_cli"}, + id="no config files override", + ), + pytest.param( + None, + "MAGIC=from_cli", + None, + "+cli.env", + {"MAGIC": "from_cli"}, + id="no config files extend", + ), + pytest.param( + "MAGIC=from_env", + "EXTRA=from_cli", + '[tool.pytest_env]\nenv_files = [".env", "missing.env"]', + "+cli.env", + {"MAGIC": "from_env", "EXTRA": "from_cli"}, + id="extend with missing config file", + ), + ], +) +def test_envfile_cli( # noqa: PLR0913, PLR0917 + pytester: pytest.Pytester, + env_file_content: str | None, + cli_file_content: str, + config: str | None, + cli_arg: str, + expected_env: dict[str, str], +) -> None: + (pytester.path / "test_cli.py").symlink_to(Path(__file__).parent / "template.py") + + if env_file_content: + (pytester.path / ".env").write_text(env_file_content, encoding="utf-8") + (pytester.path / "cli.env").write_text(cli_file_content, encoding="utf-8") + if config: + (pytester.path / "pyproject.toml").write_text(config, encoding="utf-8") + + new_env = { + "_TEST_ENV": repr(expected_env), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--envfile", cli_arg) + + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "cli_arg", + [ + pytest.param("nonexistent.env", id="missing file override"), + pytest.param("+nonexistent.env", id="missing file extend"), + ], +) +def test_envfile_cli_missing_file(pytester: pytest.Pytester, cli_arg: str) -> None: + pytester.makepyfile(test_it="def test_it() -> None:\n pass") + + new_env = { + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--envfile", cli_arg) + + assert result.ret != 0 + error_file = cli_arg.lstrip("+") + assert any(f"Environment file not found: {error_file}" in line for line in result.errlines) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 897ec6d..0000000 --- a/tox.ini +++ /dev/null @@ -1,73 +0,0 @@ -[tox] -requires = - tox>=4.34.1 - tox-uv>=1.29 -env_list = - fix - 3.14 - 3.13 - 3.12 - 3.11 - 3.10 - type - pkg_meta -skip_missing_interpreters = true - -[testenv] -description = run the tests with pytest -package = wheel -wheel_build_env = .pkg -extras = - testing -pass_env = - DIFF_AGAINST - PYTEST_* -set_env = - COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}} -commands = - coverage erase - coverage run -m pytest {tty:--color=yes} \ - --junitxml {toxworkdir}{/}junit.{envname}.xml \ - {posargs:tests} - coverage combine - coverage report - coverage html -d {envtmpdir}{/}htmlcov - -[testenv:fix] -description = run static analysis and style check using flake8 -skip_install = true -deps = - pre-commit-uv>=4.2 -pass_env = - HOMEPATH - PROGRAMDATA -commands = - pre-commit run --all-files --show-diff-on-failure - -[testenv:type] -description = run type check on code base -deps = - ty==0.0.16 -commands = - ty check --output-format concise --error-on-warning . - -[testenv:pkg_meta] -description = check that the long description is valid -skip_install = true -deps = - check-wheel-contents>=0.6.3 - twine>=6.2 - uv>=0.10.2 -commands = - uv build --sdist --wheel --out-dir {env_tmp_dir} . - twine check {env_tmp_dir}{/}* - check-wheel-contents --no-config {env_tmp_dir} - -[testenv:dev] -description = generate a DEV environment -package = editable -extras = - testing -commands = - uv pip tree - python -c 'import sys; print(sys.executable)' diff --git a/tox.toml b/tox.toml new file mode 100644 index 0000000..fe2a872 --- /dev/null +++ b/tox.toml @@ -0,0 +1,75 @@ +[env_run_base] +description = "run the tests with pytest" +package = "wheel" +wheel_build_env = ".pkg" +extras = [ "testing" ] +pass_env = [ "DIFF_AGAINST", "PYTEST_*" ] +set_env.COVERAGE_FILE = "{env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}}" +commands = [ + [ "coverage", "erase" ], + [ + "coverage", + "run", + "-m", + "pytest", + "{tty:--color=yes}", + "--junitxml", + "{toxworkdir}{/}junit.{envname}.xml", + "{posargs:tests}", + ], + [ "coverage", "combine" ], + [ "coverage", "report" ], + [ "coverage", "html", "-d", "{envtmpdir}{/}htmlcov" ], +] + +[env.dev] +description = "generate a DEV environment" +package = "editable" +extras = [ "testing" ] +commands = [ + [ "uv", "pip", "tree" ], + [ "python", "-c", "import sys; print(sys.executable)" ], +] + +[env.fix] +description = "run static analysis and style check using flake8" +skip_install = true +deps = [ "pre-commit-uv>=4.2" ] +pass_env = [ "HOMEPATH", "PROGRAMDATA" ] +commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ] + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +deps = [ + "check-wheel-contents>=0.6.3", + "twine>=6.2", + "uv>=0.10.3", +] +commands = [ + [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ], + [ "twine", "check", "{env_tmp_dir}{/}*" ], + [ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ], +] + +[env.type] +description = "run type check on code base" +deps = [ "ty==0.0.17" ] +commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "." ] ] + +[tox] +requires = [ + "tox>=4.36.1", + "tox-uv>=1.29", +] +env_list = [ + "fix", + "3.14", + "3.13", + "3.12", + "3.11", + "3.10", + "type", + "pkg_meta", +] +skip_missing_interpreters = true