Skip to content

Commit dfddb1d

Browse files
committed
perf: avoid copying resolved values during variable resolution
1 parent a00cb2e commit dfddb1d

3 files changed

Lines changed: 54 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010
### Fixed
1111

1212
- 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]
13+
- Improve the performance of variable interpolation for files with many entries by avoiding repeated copies of previously resolved values
1314

1415
## [1.2.3] - 2026-08-16
1516

src/dotenv/main.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import stat
66
import sys
77
import tempfile
8-
from collections import OrderedDict
8+
from collections import ChainMap, OrderedDict
99
from contextlib import contextmanager
1010
from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union
1111

@@ -302,13 +302,12 @@ def resolve_variables(
302302
result = None
303303
else:
304304
atoms = parse_variables(value)
305-
env: Dict[str, Optional[str]] = {}
305+
os_environ: Dict[str, Optional[str]] = {**os.environ}
306+
env: Mapping[str, Optional[str]]
306307
if override:
307-
env.update(os.environ) # type: ignore
308-
env.update(new_values)
308+
env = ChainMap(new_values, os_environ)
309309
else:
310-
env.update(new_values)
311-
env.update(os.environ) # type: ignore
310+
env = ChainMap(os_environ, new_values)
312311
result = "".join(atom.resolve(env) for atom in atoms)
313312

314313
new_values[name] = result

tests/test_main.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pytest
1111

1212
import dotenv
13-
from dotenv.main import DotEnv
13+
from dotenv.main import DotEnv, resolve_variables
1414

1515

1616
def test_set_key_no_file(tmp_path):
@@ -756,3 +756,50 @@ def test_dotenv_values_empty_value_with_inline_comment(string, expected):
756756
result = dotenv.dotenv_values(stream=io.StringIO(string))
757757

758758
assert result == expected
759+
760+
761+
@pytest.mark.parametrize(
762+
"env,values,override,expected",
763+
[
764+
# Interpolation sees earlier file bindings; file wins with override=True
765+
(
766+
{"A": "env"},
767+
[("A", "file"), ("B", "${A}")],
768+
True,
769+
{"A": "file", "B": "file"},
770+
),
771+
# ... while the environment wins with override=False
772+
(
773+
{"A": "env"},
774+
[("A", "file"), ("B", "${A}")],
775+
False,
776+
{"A": "file", "B": "env"},
777+
),
778+
# File values stay visible with override=False when the environment
779+
# does not define the interpolated key
780+
(
781+
{},
782+
[("A", "file"), ("B", "${A}")],
783+
False,
784+
{"A": "file", "B": "file"},
785+
),
786+
# Duplicate keys re-resolve against the live, winning source
787+
(
788+
{"A": "env"},
789+
[("A", "first"), ("A", "${A}-more")],
790+
True,
791+
{"A": "first-more"},
792+
),
793+
(
794+
{"A": "env"},
795+
[("A", "first"), ("A", "${A}-more")],
796+
False,
797+
{"A": "env-more"},
798+
),
799+
# Key without value stays None and resolves to "" elsewhere
800+
({}, [("A", None), ("B", "${A}")], True, {"A": None, "B": ""}),
801+
],
802+
)
803+
def test_resolve_variables_override_precedence(env, values, override, expected):
804+
with mock.patch.dict(os.environ, env, clear=True):
805+
assert dict(resolve_variables(values, override=override)) == expected

0 commit comments

Comments
 (0)