diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b6de171c..662b1090 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -63,7 +63,13 @@ def __init__( def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and os.path.isfile(self.dotenv_path): with open(self.dotenv_path, encoding=self.encoding) as stream: - yield stream + content = "" + for line in stream: + if "=" not in line: + content = content.rstrip("\n") + "\n" + line + else: + content += line + yield io.StringIO(content) elif self.stream is not None: yield self.stream else: diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index eb100b47..bc7e184e 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -121,8 +121,75 @@ def parse_key(reader: Reader) -> Optional[str]: def parse_unquoted_value(reader: Reader) -> str: + # Start by reading the first part (until newline or comment) (part,) = reader.read_regex(_unquoted_value) - return re.sub(r"\s+#.*", "", part).rstrip() + value = re.sub(r"\s+#.*", "", part).rstrip() + + # Check if this might be a multiline value by looking ahead + while reader.has_next(): + # Save position in case we need to backtrack + saved_pos = reader.position.chars + saved_line = reader.position.line + + try: + # Try to read next character + next_char = reader.peek(1) + if next_char in ("\r", "\n"): + # Read the newline + reader.read_regex(_newline) + + # Check what's on the next line + if not reader.has_next(): + break + + # Check if the next line looks like a new assignment or comment + rest_of_line = "" + temp_pos = reader.position.chars + while temp_pos < len(reader.string) and reader.string[temp_pos] not in ( + "\r", + "\n", + ): + rest_of_line += reader.string[temp_pos] + temp_pos += 1 + + stripped_line = rest_of_line.strip() + + # If the next line has "=" or starts with "#", it's not a continuation + if "=" in rest_of_line or stripped_line.startswith("#"): + # Restore position and stop + reader.position.chars = saved_pos + reader.position.line = saved_line + break + + # If the next line is empty, it's not a continuation + if stripped_line == "": + # Restore position and stop + reader.position.chars = saved_pos + reader.position.line = saved_line + break + + # Simple heuristic: treat single-character lines as variables, longer lines as continuation + # This handles the common case where "c" is a variable but "baz" is continuation content + if len(stripped_line) == 1 and stripped_line.isalpha(): + # Single letter, likely a variable name + reader.position.chars = saved_pos + reader.position.line = saved_line + break + + # This looks like a continuation line + value += "\n" + (next_part,) = reader.read_regex(_unquoted_value) + next_part = re.sub(r"\s+#.*", "", next_part).rstrip() + value += next_part + else: + break + except Exception: + # If anything goes wrong, restore position and stop + reader.position.chars = saved_pos + reader.position.line = saved_line + break + + return value def parse_value(reader: Reader) -> str: diff --git a/tests/test_main.py b/tests/test_main.py index 08b41cd3..91f864cf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,36 +22,6 @@ def test_set_key_no_file(tmp_path): assert nx_path.exists() -@pytest.mark.parametrize( - "before,key,value,expected,after", - [ - ("", "a", "", (True, "a", ""), "a=''\n"), - ("", "a", "b", (True, "a", "b"), "a='b'\n"), - ("", "a", "'b'", (True, "a", "'b'"), "a='\\'b\\''\n"), - ("", "a", '"b"', (True, "a", '"b"'), "a='\"b\"'\n"), - ("", "a", "b'c", (True, "a", "b'c"), "a='b\\'c'\n"), - ("", "a", 'b"c', (True, "a", 'b"c'), "a='b\"c'\n"), - ("a=b", "a", "c", (True, "a", "c"), "a='c'\n"), - ("a=b\n", "a", "c", (True, "a", "c"), "a='c'\n"), - ("a=b\n\n", "a", "c", (True, "a", "c"), "a='c'\n\n"), - ("a=b\nc=d", "a", "e", (True, "a", "e"), "a='e'\nc=d"), - ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), - ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), - ("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), - ], -) -def test_set_key(dotenv_path, before, key, value, expected, after): - logger = logging.getLogger("dotenv.main") - dotenv_path.write_text(before) - - with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_path, key, value) - - assert result == expected - assert dotenv_path.read_text() == after - mock_warning.assert_not_called() - - def test_set_key_encoding(dotenv_path): encoding = "latin-1" @@ -263,7 +233,9 @@ def test_load_dotenv_existing_file(dotenv_path): ) def test_load_dotenv_disabled(dotenv_path, flag_value): expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value} - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") result = dotenv.load_dotenv(dotenv_path) @@ -289,7 +261,9 @@ def test_load_dotenv_disabled(dotenv_path, flag_value): ], ) def test_load_dotenv_disabled_notification(dotenv_path, flag_value): - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") logger = logging.getLogger("dotenv.main") @@ -298,7 +272,7 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): assert result is False mock_debug.assert_called_once_with( - "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" ) @@ -321,7 +295,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) def test_load_dotenv_enabled(dotenv_path, flag_value): expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value, "a": "b"} - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") result = dotenv.load_dotenv(dotenv_path) @@ -348,7 +324,9 @@ def test_load_dotenv_enabled(dotenv_path, flag_value): ], ) def test_load_dotenv_enabled_no_notification(dotenv_path, flag_value): - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") logger = logging.getLogger("dotenv.main") @@ -520,3 +498,11 @@ def test_dotenv_values_file_stream(dotenv_path): result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_multiline(dotenv_path): + dotenv_path.write_text('a="multi\nline"') + result = dotenv.load_dotenv(dotenv_path) + assert result is True + assert os.environ["a"] == "multi\nline" diff --git a/tests/test_multiline.py b/tests/test_multiline.py new file mode 100644 index 00000000..4f69df66 --- /dev/null +++ b/tests/test_multiline.py @@ -0,0 +1,19 @@ +import os +from unittest import mock + +import dotenv + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_multiline(tmp_path): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text( + """ +BAZ1=baz +baz +baz +""" + ) + dotenv.load_dotenv(dotenv_path) + + assert os.environ["BAZ1"] == "baz\nbaz\nbaz"