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
8 changes: 7 additions & 1 deletion src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
69 changes: 68 additions & 1 deletion src/dotenv/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 21 additions & 35 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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"
)


Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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"
19 changes: 19 additions & 0 deletions tests/test_multiline.py
Original file line number Diff line number Diff line change
@@ -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"