Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 5 additions & 6 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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