diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..ba113dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] +- Improve the performance of variable interpolation for files with many entries by avoiding repeated copies of previously resolved values ## [1.2.3] - 2026-08-16 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..81a9fc08 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -5,7 +5,7 @@ import stat import sys import tempfile -from collections import OrderedDict +from collections import ChainMap, OrderedDict from contextlib import contextmanager from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union @@ -302,13 +302,12 @@ def resolve_variables( result = None else: atoms = parse_variables(value) - env: Dict[str, Optional[str]] = {} + os_environ: Dict[str, Optional[str]] = {**os.environ} + env: Mapping[str, Optional[str]] if override: - env.update(os.environ) # type: ignore - env.update(new_values) + env = ChainMap(new_values, os_environ) else: - env.update(new_values) - env.update(os.environ) # type: ignore + env = ChainMap(os_environ, new_values) result = "".join(atom.resolve(env) for atom in atoms) new_values[name] = result diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d4c5c..95b0db53 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,7 +10,7 @@ import pytest import dotenv -from dotenv.main import DotEnv +from dotenv.main import DotEnv, resolve_variables def test_set_key_no_file(tmp_path): @@ -756,3 +756,50 @@ def test_dotenv_values_empty_value_with_inline_comment(string, expected): result = dotenv.dotenv_values(stream=io.StringIO(string)) assert result == expected + + +@pytest.mark.parametrize( + "env,values,override,expected", + [ + # Interpolation sees earlier file bindings; file wins with override=True + ( + {"A": "env"}, + [("A", "file"), ("B", "${A}")], + True, + {"A": "file", "B": "file"}, + ), + # ... while the environment wins with override=False + ( + {"A": "env"}, + [("A", "file"), ("B", "${A}")], + False, + {"A": "file", "B": "env"}, + ), + # File values stay visible with override=False when the environment + # does not define the interpolated key + ( + {}, + [("A", "file"), ("B", "${A}")], + False, + {"A": "file", "B": "file"}, + ), + # Duplicate keys re-resolve against the live, winning source + ( + {"A": "env"}, + [("A", "first"), ("A", "${A}-more")], + True, + {"A": "first-more"}, + ), + ( + {"A": "env"}, + [("A", "first"), ("A", "${A}-more")], + False, + {"A": "env-more"}, + ), + # Key without value stays None and resolves to "" elsewhere + ({}, [("A", None), ("B", "${A}")], True, {"A": None, "B": ""}), + ], +) +def test_resolve_variables_override_precedence(env, values, override, expected): + with mock.patch.dict(os.environ, env, clear=True): + assert dict(resolve_variables(values, override=override)) == expected