From e1476acb73302dee77877c29381f334fbbf5b5a4 Mon Sep 17 00:00:00 2001 From: Taran Pal Singh <36taransingh5@gmail.com> Date: Wed, 28 Oct 2026 20:10:04 +0000 Subject: [PATCH 1/4] Track value quote style through the parser --- src/dotenv/parser.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index b3b55070..a3b5c6d5 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -8,6 +8,7 @@ Optional, Pattern, Sequence, + Tuple, ) @@ -44,6 +45,10 @@ class Binding(NamedTuple): value: Optional[str] original: Original error: bool + # The quote character surrounding the value ("'", '"' or None for + # unquoted/empty values). Single-quoted values are literal: callers must + # not interpolate them. + quote: Optional[str] = None class Position: @@ -127,18 +132,18 @@ def parse_unquoted_value(reader: Reader) -> str: return re.sub(r"\s+#.*", "", part).rstrip() -def parse_value(reader: Reader) -> str: +def parse_value(reader: Reader) -> Tuple[str, Optional[str]]: char = reader.peek(1) if char == "'": (value,) = reader.read_regex(_single_quoted_value) - return decode_escapes(_single_quote_escapes, value) + return decode_escapes(_single_quote_escapes, value), "'" elif char == '"': (value,) = reader.read_regex(_double_quoted_value) - return decode_escapes(_double_quote_escapes, value) + return decode_escapes(_double_quote_escapes, value), '"' elif char in ("", "\n", "\r"): - return "" + return "", None else: - return parse_unquoted_value(reader) + return parse_unquoted_value(reader), None def parse_binding(reader: Reader) -> Binding: @@ -155,6 +160,7 @@ def parse_binding(reader: Reader) -> Binding: reader.read_regex(_export) key = parse_key(reader) reader.read_regex(_whitespace) + quote: Optional[str] = None if reader.peek(1) == "=": (equal_sign,) = reader.read_regex(_equal_sign) # If there is whitespace after `=` and the value starts with `#`, @@ -164,7 +170,7 @@ def parse_binding(reader: Reader) -> Binding: if len(equal_sign) > 1 and reader.peek(1) == "#": value: Optional[str] = "" else: - value = parse_value(reader) + value, quote = parse_value(reader) else: value = None reader.read_regex(_comment) @@ -174,6 +180,7 @@ def parse_binding(reader: Reader) -> Binding: value=value, original=reader.get_marked(), error=False, + quote=quote, ) except Error: reader.read_regex(_rest_of_line) From 6ad4c5fc9cce149c8a1c89359b11d0b9636488dc Mon Sep 17 00:00:00 2001 From: Taran Pal Singh <36taransingh5@gmail.com> Date: Wed, 28 Oct 2026 20:10:56 +0000 Subject: [PATCH 2/4] Do not interpolate single-quoted values when resolving variables --- src/dotenv/main.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..9abcd788 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -81,22 +81,26 @@ def dict(self) -> Dict[str, Optional[str]]: if self._dict is not None: return self._dict - raw_values = self.parse() + raw_values = list(self._parse_full()) if self.interpolate: self._dict = OrderedDict( resolve_variables(raw_values, override=self.override) ) else: - self._dict = OrderedDict(raw_values) + self._dict = OrderedDict((key, value) for key, value, _ in raw_values) return self._dict def parse(self) -> Iterator[Tuple[str, Optional[str]]]: + for key, value, _quote in self._parse_full(): + yield key, value + + def _parse_full(self) -> Iterator[Tuple[str, Optional[str], Optional[str]]]: with self._get_stream() as stream: for mapping in with_warn_for_invalid_lines(parse_stream(stream)): if mapping.key is not None: - yield mapping.key, mapping.value + yield mapping.key, mapping.value, mapping.quote def set_as_environment_variables(self) -> bool: """ @@ -331,14 +335,19 @@ def unset_key( def resolve_variables( - values: Iterable[Tuple[str, Optional[str]]], + values: Iterable[Tuple[str, Optional[str], Optional[str]]], override: bool, ) -> Mapping[str, Optional[str]]: new_values: Dict[str, Optional[str]] = {} - for name, value in values: + for name, value, quote in values: if value is None: result = None + elif quote == "'": + # Single-quoted values are literal (as in POSIX shells): they are + # stored and made available to later values verbatim, without + # variable expansion. + result = value else: atoms = parse_variables(value) env: Dict[str, Optional[str]] = {} From 59ff52611d6dea654693db5a524f7fe5601cea36 Mon Sep 17 00:00:00 2001 From: Taran Pal Singh <36taransingh5@gmail.com> Date: Wed, 28 Oct 2026 20:12:02 +0000 Subject: [PATCH 3/4] Update tests for single-quote literal semantics (#422) --- tests/test_main.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..81738166 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -441,7 +441,9 @@ def test_empty_dotenv_dict_is_cached(tmp_path): dotenv_path.write_text("") dotenv_obj = DotEnv(dotenv_path) - with mock.patch.object(dotenv_obj, "parse", wraps=dotenv_obj.parse) as mock_parse: + with mock.patch.object( + dotenv_obj, "_parse_full", wraps=dotenv_obj._parse_full + ) as mock_parse: assert dotenv_obj.dict() == {} assert dotenv_obj.dict() == {} @@ -894,9 +896,13 @@ def test_dotenv_values_file(dotenv_path): # Undefined ({}, "a=${b}", True, {"a": ""}), ({}, "a=${b:-d}", True, {"a": "d"}), - # With quotes + # With quotes (single quotes are literal, as in POSIX shells - #422) ({"b": "c"}, 'a="${b}"', True, {"a": "c"}), - ({"b": "c"}, "a='${b}'", True, {"a": "c"}), + ({"b": "c"}, "a='${b}'", True, {"a": "${b}"}), + ({"b": "c"}, "a='${b}'", False, {"a": "${b}"}), + # Single-quoted values are still visible to later values verbatim + ({}, "b='c ${d}'\na=${b}", True, {"a": "c ${d}", "b": "c ${d}"}), + ({}, "a='x${b}y'", True, {"a": "x${b}y"}), # With surrounding text ({"b": "c"}, "a=x${b}y", True, {"a": "xcy"}), # Self-referential From fa541eec5fd38e02b0b32b78649c0200c9976ba3 Mon Sep 17 00:00:00 2001 From: Taran Pal Singh <36taransingh5@gmail.com> Date: Wed, 28 Oct 2026 20:12:29 +0000 Subject: [PATCH 4/4] Update tests for single-quote literal semantics (#422) --- tests/test_parser.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_parser.py b/tests/test_parser.py index 982305a3..70a1979a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -254,6 +254,7 @@ value="b c ", original=Original(string="a='b c '", line=1), error=False, + quote="'", ) ], ), @@ -265,6 +266,7 @@ value="b c ", original=Original(string='a="b c "', line=1), error=False, + quote='"', ) ], ), @@ -298,6 +300,7 @@ value="b\nc", original=Original(string='a="b\nc"', line=1), error=False, + quote='"', ) ], ), @@ -309,6 +312,7 @@ value="b\nc", original=Original(string="a='b\nc'", line=1), error=False, + quote="'", ) ], ), @@ -320,6 +324,7 @@ value="b\nc", original=Original(string='a="b\nc"', line=1), error=False, + quote='"', ) ], ), @@ -331,6 +336,7 @@ value="b\nc", original=Original(string='a="b\\nc"', line=1), error=False, + quote='"', ) ], ), @@ -342,6 +348,7 @@ value="b\\nc", original=Original(string="a='b\\nc'", line=1), error=False, + quote="'", ) ], ), @@ -353,6 +360,7 @@ value='b"c', original=Original(string='a="b\\"c"', line=1), error=False, + quote='"', ) ], ), @@ -364,6 +372,7 @@ value="b'c", original=Original(string="a='b\\'c'", line=1), error=False, + quote="'", ) ], ), @@ -375,6 +384,7 @@ value="b\\c", original=Original(string="a='b\\\\c'", line=1), error=False, + quote="'", ) ], ), @@ -386,6 +396,7 @@ value="b\\c", original=Original(string='a="b\\\\c"', line=1), error=False, + quote='"', ) ], ), @@ -399,12 +410,14 @@ value="b\\", original=Original(string="a='b\\\\'\n", line=1), error=False, + quote="'", ), Binding( key="c", value="d", original=Original(string="c='d'", line=2), error=False, + quote="'", ), ], ), @@ -416,12 +429,14 @@ value="b\\", original=Original(string='a="b\\\\"\n', line=1), error=False, + quote='"', ), Binding( key="c", value="d", original=Original(string='c="d"', line=2), error=False, + quote='"', ), ], ), @@ -433,6 +448,7 @@ value="b\\'c", original=Original(string="a='b\\\\\\'c'", line=1), error=False, + quote="'", ) ], ), @@ -455,6 +471,7 @@ value="à", original=Original(string='a="à"', line=1), error=False, + quote='"', ) ], ), @@ -664,6 +681,7 @@ value="b\nc", original=Original(string='a="b\nc"\n', line=2), error=False, + quote='"', ), Binding( key="d", @@ -717,6 +735,7 @@ string='uglyKey[%$="S3cr3t_P4ssw#rD" #\n', line=1 ), error=False, + quote='"', ), Binding( key="a",