From 2e928a2cd9c8d528d76cb27ea6fde890c3bcdf74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 12 Mar 2026 15:20:18 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(env):=20preserve=20existing=20?= =?UTF-8?q?env=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams need .env files to provide defaults without clobbering CI or ad-hoc overrides. The current behavior overwrites even when values are already set, including --envfile, which makes injected configuration unreliable. Add env_files_skip_if_set for TOML/INI and apply it to CLI env files with SKIP reporting and tests. Refresh tox and build tool versions to match current formatting output and keep the dev/type tooling aligned. --- README.md | 22 +++++++++++++++-- pyproject.toml | 4 +-- src/pytest_env/plugin.py | 46 ++++++++++++++++++++++++++-------- tests/test_env.py | 49 +++++++++++++++++++++++++++++++++++++ tests/test_verbose.py | 25 +++++++++++++++++++ tox.toml | 53 ++++++++++++++++++++-------------------- 6 files changed, 158 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 2c7d166..601e414 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,22 @@ pytest --envfile .env.local # ignore configured env_files, load only t pytest --envfile +.env.override # load configured env_files first, then this file on top ``` +To keep existing environment variables (including with `--envfile`), set `env_files_skip_if_set = true`: + +```toml +[tool.pytest_env] +env_files = [".env", ".env.test"] +env_files_skip_if_set = true +``` + +```ini +[pytest] +env_files = + .env + .env.test +env_files_skip_if_set = true +``` + ### Control variable behavior Variables set as plain values are assigned directly. For more control, use inline tables with the `transform`, @@ -107,7 +123,8 @@ TEMP_VAR = { unset = true } ``` `transform` expands `{VAR}` placeholders using existing environment variables. `skip_if_set` leaves the variable -unchanged when it already exists. `unset` removes it entirely (different from setting to empty string). +unchanged when it already exists. For `.env` files, use `env_files_skip_if_set = true`. `unset` removes it entirely +(different from setting to empty string). ### Set different environments for test suites @@ -253,7 +270,8 @@ When multiple sources define the same variable, precedence applies in this order 1. Inline variables in configuration files (TOML or INI format). 1. Variables from `.env` files loaded via `env_files`. When using `--envfile`, CLI files take precedence over configuration-based `env_files`. -1. Variables already present in the environment (preserved when `skip_if_set = true` or `D:` flag is used). +1. Variables already present in the environment (preserved when `skip_if_set = true`, `D:` flag is used, or + `env_files_skip_if_set = true`). When multiple configuration formats are present, TOML native format (`[pytest_env]` / `[tool.pytest_env]`) takes precedence over INI format. Among TOML files, the first file with a `pytest_env` section wins, checked in order: diff --git a/pyproject.toml b/pyproject.toml index bea77bb..75b60cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.5", - "hatchling>=1.28", + "hatchling>=1.29", ] [project] @@ -38,7 +38,7 @@ dynamic = [ ] dependencies = [ "pytest>=9.0.2", - "python-dotenv>=1.2.1", + "python-dotenv>=1.2.2", "tomli>=2.4; python_version<'3.11'", ] optional-dependencies.testing = [ diff --git a/src/pytest_env/plugin.py b/src/pytest_env/plugin.py index 9bedb92..023192a 100644 --- a/src/pytest_env/plugin.py +++ b/src/pytest_env/plugin.py @@ -27,6 +27,12 @@ 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.addini( + "env_files_skip_if_set", + type="bool", + help="only set .env file variables when not already defined", + default=False, + ) parser.addoption( "--envfile", action="store", @@ -65,10 +71,19 @@ def pytest_load_initial_conftests( actions: list[tuple[str, str, str, str]] = [] env_files_list: list[str] = [] + env_files_skip_if_set: bool | None = None if toml_config := _find_toml_config(early_config): - env_files_list, _ = _load_toml_config(toml_config) + env_files_list, _, env_files_skip_if_set = _load_toml_config(toml_config) + + if env_files_skip_if_set is None: + env_files_skip_if_set = bool(early_config.getini("env_files_skip_if_set")) - _apply_env_files(early_config, env_files_list, actions if verbose else None) + _apply_env_files( + early_config, + env_files_list, + actions if verbose else None, + skip_if_set=env_files_skip_if_set, + ) _apply_entries(early_config, actions if verbose else None) if verbose and actions: @@ -79,13 +94,20 @@ def _apply_env_files( early_config: pytest.Config, env_files_list: list[str], actions: list[tuple[str, str, str, str]] | None, + *, + skip_if_set: bool = False, ) -> None: + preexisting = dict(os.environ) if skip_if_set else {} for env_file in _load_env_files(early_config, env_files_list): for key, value in dotenv_values(env_file).items(): if value is not None: - os.environ[key] = value - if actions is not None: - actions.append(("SET", key, value, str(env_file))) + if skip_if_set and key in preexisting: + if actions is not None: + actions.append(("SKIP", key, preexisting[key], str(env_file))) + else: + os.environ[key] = value + if actions is not None: + actions.append(("SET", key, value, str(env_file))) def _apply_entries( @@ -146,7 +168,7 @@ def _find_toml_config(early_config: pytest.Config) -> Path | None: def _config_source(early_config: pytest.Config) -> str: """Describe the configuration source for verbose output.""" if toml_path := _find_toml_config(early_config): - _, entries = _load_toml_config(toml_path) + _, entries, _ = _load_toml_config(toml_path) if entries: return str(toml_path) if early_config.inipath: @@ -154,7 +176,7 @@ def _config_source(early_config: pytest.Config) -> str: return "config" # pragma: no cover -def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]: +def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry], bool | None]: """Load env_files and entries from TOML config file.""" with config_path.open("rb") as file_handler: config = tomllib.load(file_handler) @@ -164,13 +186,15 @@ def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]: pytest_env_config = config.get("pytest_env", {}) if not pytest_env_config: - return [], [] + return [], [], None raw_env_files = pytest_env_config.get("env_files") env_files = [str(f) for f in raw_env_files] if isinstance(raw_env_files, list) else [] + raw_skip = pytest_env_config.get("env_files_skip_if_set") + env_files_skip_if_set = raw_skip if isinstance(raw_skip, bool) else None entries = list(_parse_toml_config(pytest_env_config)) - return env_files, entries + return env_files, entries, env_files_skip_if_set def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]: @@ -199,7 +223,7 @@ def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Genera def _load_values(early_config: pytest.Config) -> Iterator[Entry]: """Load env entries from config, preferring TOML over INI.""" if toml_config := _find_toml_config(early_config): - _, entries = _load_toml_config(toml_config) + _, entries, _ = _load_toml_config(toml_config) if entries: yield from entries return @@ -224,6 +248,8 @@ def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]: for key, entry in config.items(): if key == "env_files" and isinstance(entry, list): continue + if key == "env_files_skip_if_set" and isinstance(entry, bool): + continue if isinstance(entry, dict): unset = bool(entry.get("unset")) value = str(entry.get("value", "")) if not unset else "" diff --git a/tests/test_env.py b/tests/test_env.py index afcdfbc..55b3b0c 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -408,6 +408,30 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917 "pyproject", id="skip if set respects env file", ), + pytest.param( + {"MAGIC": "original"}, + "MAGIC=from_file", + dedent("""\ + [tool.pytest_env] + env_files = [".env"] + env_files_skip_if_set = true + """), + {"MAGIC": "original"}, + "pyproject", + id="env_files_skip_if_set pyproject", + ), + pytest.param( + {"MAGIC": "original"}, + "MAGIC=from_file", + dedent("""\ + [pytest] + env_files = .env + env_files_skip_if_set = true + """), + {"MAGIC": "original"}, + "ini", + id="env_files_skip_if_set ini", + ), pytest.param( {}, "=no_key\nVALID=yes", @@ -623,6 +647,31 @@ def test_envfile_cli( # noqa: PLR0913, PLR0917 result.assert_outcomes(passed=1) +def test_envfile_cli_skip_if_set(pytester: pytest.Pytester) -> None: + (pytester.path / "test_cli_skip.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "cli.env").write_text("MAGIC=from_cli", encoding="utf-8") + (pytester.path / "pyproject.toml").write_text( + dedent("""\ + [tool.pytest_env] + env_files_skip_if_set = true + """), + encoding="utf-8", + ) + + expected_env = {"MAGIC": "original"} + new_env = { + "MAGIC": "original", + "_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.env") + + result.assert_outcomes(passed=1) + + @pytest.mark.parametrize( "cli_arg", [ diff --git a/tests/test_verbose.py b/tests/test_verbose.py index df9776f..918061a 100644 --- a/tests/test_verbose.py +++ b/tests/test_verbose.py @@ -89,6 +89,31 @@ def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*SET*FROM_FILE=value*(from*.env*"]) +def test_verbose_shows_skip_from_env_file(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / ".env").write_text("FROM_FILE=value", encoding="utf-8") + (pytester.path / "pyproject.toml").write_text( + dedent("""\ + [tool.pytest_env] + env_files = [".env"] + env_files_skip_if_set = true + """), + encoding="utf-8", + ) + + new_env = { + "FROM_FILE": "original", + "_TEST_ENV": repr({"FROM_FILE": "original"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*SKIP*FROM_FILE=original*(from*.env*"]) + + def test_verbose_shows_skip(pytester: pytest.Pytester) -> None: (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") (pytester.path / "pytest.ini").write_text("[pytest]\nenv = D:EXISTING=new", encoding="utf-8") diff --git a/tox.toml b/tox.toml index fe2a872..d2b0857 100644 --- a/tox.toml +++ b/tox.toml @@ -1,3 +1,19 @@ +requires = [ + "tox>=4.36.1", + "tox-uv>=1.29", +] +env_list = [ + "3.14", + "3.13", + "3.12", + "3.11", + "3.10", + "fix", + "pkg_meta", + "type", +] +skip_missing_interpreters = true + [env_run_base] description = "run the tests with pytest" package = "wheel" @@ -22,19 +38,10 @@ commands = [ [ "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" ] +deps = [ "pre-commit-uv>=4.2.1" ] pass_env = [ "HOMEPATH", "PROGRAMDATA" ] commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ] @@ -44,7 +51,7 @@ skip_install = true deps = [ "check-wheel-contents>=0.6.3", "twine>=6.2", - "uv>=0.10.3", + "uv>=0.10.9", ] commands = [ [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ], @@ -54,22 +61,14 @@ commands = [ [env.type] description = "run type check on code base" -deps = [ "ty==0.0.17" ] +deps = [ "ty==0.0.22" ] 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", +[env.dev] +description = "generate a DEV environment" +package = "editable" +extras = [ "testing" ] +commands = [ + [ "uv", "pip", "tree" ], + [ "python", "-c", "import sys; print(sys.executable)" ], ] -skip_missing_interpreters = true