Skip to content

Commit 002bfb6

Browse files
Add on_duplicate parameter to handle duplicate keys
Closes #591 Adds on_duplicate parameter to DotEnv, load_dotenv, and dotenv_values with three modes: - warn (default): logs a warning, latter value wins - raise: raises ValueError on first duplicate found - ignore: silently uses latter value, old behaviour explicit
1 parent bca6644 commit 002bfb6

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

src/dotenv/main.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from .parser import Binding, parse_stream
1313
from .variables import parse_variables
1414

15+
_DUPLICATE_VALUES = ("warn", "raise", "ignore")
16+
1517
# A type alias for a string path to be used for the paths in this file.
1618
# These paths may flow to `open()` and `os.replace()`.
1719
StrPath = Union[str, "os.PathLike[str]"]
@@ -48,14 +50,21 @@ def __init__(
4850
encoding: Optional[str] = None,
4951
interpolate: bool = True,
5052
override: bool = True,
53+
on_duplicate: str = "warn",
5154
) -> None:
55+
if on_duplicate not in _DUPLICATE_VALUES:
56+
raise ValueError(
57+
f"Invalid value for on_duplicate: {on_duplicate!r}. "
58+
f"Expected one of: {', '.join(_DUPLICATE_VALUES)}"
59+
)
5260
self.dotenv_path: Optional[StrPath] = dotenv_path
5361
self.stream: Optional[IO[str]] = stream
5462
self._dict: Optional[Dict[str, Optional[str]]] = None
5563
self.verbose: bool = verbose
5664
self.encoding: Optional[str] = encoding
5765
self.interpolate: bool = interpolate
5866
self.override: bool = override
67+
self.on_duplicate: str = on_duplicate
5968

6069
@contextmanager
6170
def _get_stream(self) -> Iterator[IO[str]]:
@@ -90,8 +99,26 @@ def dict(self) -> Dict[str, Optional[str]]:
9099

91100
def parse(self) -> Iterator[Tuple[str, Optional[str]]]:
92101
with self._get_stream() as stream:
102+
seen_keys: Dict[str, int] = {}
93103
for mapping in with_warn_for_invalid_lines(parse_stream(stream)):
94104
if mapping.key is not None:
105+
if mapping.key in seen_keys:
106+
msg = (
107+
"Duplicate key %r found in %s "
108+
"(first defined on line %d, redefined on line %d)."
109+
)
110+
args = (
111+
mapping.key,
112+
self.dotenv_path or "<stream>",
113+
seen_keys[mapping.key],
114+
mapping.original.line,
115+
)
116+
if self.on_duplicate == "raise":
117+
raise ValueError(msg % args)
118+
elif self.on_duplicate == "warn":
119+
logger.warning(msg, *args)
120+
else:
121+
seen_keys[mapping.key] = mapping.original.line
95122
yield mapping.key, mapping.value
96123

97124
def set_as_environment_variables(self) -> bool:
@@ -387,6 +414,7 @@ def load_dotenv(
387414
override: bool = False,
388415
interpolate: bool = True,
389416
encoding: Optional[str] = "utf-8",
417+
on_duplicate: str = "warn",
390418
) -> bool:
391419
"""Parse a .env file and then load all the variables found as environment variables.
392420
@@ -426,6 +454,7 @@ def load_dotenv(
426454
interpolate=interpolate,
427455
override=override,
428456
encoding=encoding,
457+
on_duplicate=on_duplicate,
429458
)
430459
return dotenv.set_as_environment_variables()
431460

@@ -436,6 +465,7 @@ def dotenv_values(
436465
verbose: bool = False,
437466
interpolate: bool = True,
438467
encoding: Optional[str] = "utf-8",
468+
on_duplicate: str = "warn",
439469
) -> Dict[str, Optional[str]]:
440470
"""
441471
Parse a .env file and return its content as a dict.
@@ -464,6 +494,7 @@ def dotenv_values(
464494
interpolate=interpolate,
465495
override=True,
466496
encoding=encoding,
497+
on_duplicate=on_duplicate,
467498
).dict()
468499

469500

tests/test_on_duplicate.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import logging
2+
import os
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
import dotenv
8+
from dotenv.main import DotEnv
9+
10+
11+
def _write_env(tmp_path, content):
12+
env_file = tmp_path / ".env"
13+
env_file.write_text(content)
14+
return env_file
15+
16+
17+
class TestOnDuplicate:
18+
def test_warn_emits_warning(self, tmp_path):
19+
env_file = _write_env(tmp_path, "FOO=first\nBAR=ok\nFOO=second\n")
20+
with patch.object(logging.getLogger("dotenv.main"), "warning") as mock_warn:
21+
result = DotEnv(env_file, on_duplicate="warn").dict()
22+
assert mock_warn.called
23+
assert "Duplicate key" in mock_warn.call_args[0][0]
24+
assert result["FOO"] == "second"
25+
26+
def test_raise_raises_valueerror(self, tmp_path):
27+
env_file = _write_env(tmp_path, "FOO=first\nFOO=second\n")
28+
with pytest.raises(ValueError, match="Duplicate key"):
29+
DotEnv(env_file, on_duplicate="raise").dict()
30+
31+
def test_ignore_no_warning(self, tmp_path):
32+
env_file = _write_env(tmp_path, "FOO=first\nFOO=second\n")
33+
with patch.object(logging.getLogger("dotenv.main"), "warning") as mock_warn:
34+
result = DotEnv(env_file, on_duplicate="ignore").dict()
35+
assert not mock_warn.called
36+
assert result["FOO"] == "second"
37+
38+
def test_invalid_option_raises(self, tmp_path):
39+
env_file = _write_env(tmp_path, "")
40+
with pytest.raises(ValueError, match="Invalid value for on_duplicate"):
41+
DotEnv(env_file, on_duplicate="bad-value")
42+
43+
def test_load_dotenv_warn(self, tmp_path):
44+
env_file = _write_env(tmp_path, "MYKEY=first\nMYKEY=second\n")
45+
with patch.object(logging.getLogger("dotenv.main"), "warning") as mock_warn:
46+
dotenv.load_dotenv(env_file, override=True, on_duplicate="warn")
47+
assert mock_warn.called
48+
assert "Duplicate key" in mock_warn.call_args[0][0]
49+
del os.environ["MYKEY"]
50+
51+
def test_load_dotenv_raise(self, tmp_path):
52+
env_file = _write_env(tmp_path, "MYKEY=first\nMYKEY=second\n")
53+
with pytest.raises(ValueError, match="Duplicate key"):
54+
dotenv.load_dotenv(env_file, on_duplicate="raise")
55+
56+
def test_dotenv_values_warn(self, tmp_path):
57+
env_file = _write_env(tmp_path, "Z=1\nZ=2\n")
58+
with patch.object(logging.getLogger("dotenv.main"), "warning") as mock_warn:
59+
result = dotenv.dotenv_values(env_file, on_duplicate="warn")
60+
assert mock_warn.called
61+
assert result["Z"] == "2"
62+
63+
def test_dotenv_values_raise(self, tmp_path):
64+
env_file = _write_env(tmp_path, "Z=1\nZ=2\n")
65+
with pytest.raises(ValueError, match="Duplicate key"):
66+
dotenv.dotenv_values(env_file, on_duplicate="raise")
67+
68+
def test_no_duplicate_no_warning(self, tmp_path):
69+
env_file = _write_env(tmp_path, "A=1\nB=2\n")
70+
with patch.object(logging.getLogger("dotenv.main"), "warning") as mock_warn:
71+
result = dotenv.dotenv_values(env_file, on_duplicate="warn")
72+
assert not mock_warn.called
73+
assert result == {"A": "1", "B": "2"}

0 commit comments

Comments
 (0)