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
52 changes: 43 additions & 9 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,22 @@ def _load_dotenv_disabled() -> bool:
return value in {"1", "true", "t", "yes", "y"}


def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]:
def with_warn_for_invalid_lines(
mappings: Iterator[Binding],
strict: bool = False,
) -> Iterator[Binding]:
for mapping in mappings:
if mapping.error:
logger.warning(
"python-dotenv could not parse statement starting at line %s",
mapping.original.line,
)
if strict:
raise ValueError(
"python-dotenv could not parse statement starting at line %s"
% mapping.original.line,
)
else:
logger.warning(
"python-dotenv could not parse statement starting at line %s",
mapping.original.line,
)
yield mapping


Expand All @@ -48,6 +57,7 @@ def __init__(
encoding: Optional[str] = None,
interpolate: bool = True,
override: bool = True,
strict: bool = False,
) -> None:
self.dotenv_path: Optional[StrPath] = dotenv_path
self.stream: Optional[IO[str]] = stream
Expand All @@ -56,6 +66,7 @@ def __init__(
self.encoding: Optional[str] = encoding
self.interpolate: bool = interpolate
self.override: bool = override
self.strict: bool = strict

@contextmanager
def _get_stream(self) -> Iterator[IO[str]]:
Expand All @@ -65,7 +76,12 @@ def _get_stream(self) -> Iterator[IO[str]]:
elif self.stream is not None:
yield self.stream
else:
if self.verbose:
if self.strict:
raise FileNotFoundError(
"python-dotenv could not find configuration file %s."
% (self.dotenv_path or ".env"),
)
elif self.verbose:
logger.info(
"python-dotenv could not find configuration file %s.",
self.dotenv_path or ".env",
Expand All @@ -90,7 +106,9 @@ def dict(self) -> Dict[str, Optional[str]]:

def parse(self) -> Iterator[Tuple[str, Optional[str]]]:
with self._get_stream() as stream:
for mapping in with_warn_for_invalid_lines(parse_stream(stream)):
for mapping in with_warn_for_invalid_lines(
parse_stream(stream), strict=self.strict
):
if mapping.key is not None:
yield mapping.key, mapping.value

Expand Down Expand Up @@ -387,18 +405,25 @@ def load_dotenv(
override: bool = False,
interpolate: bool = True,
encoding: Optional[str] = "utf-8",
strict: bool = False,
) -> bool:
"""Parse a .env file and then load all the variables found as environment variables.

Parameters:
dotenv_path: Absolute or relative path to .env file.
stream: Text stream (such as `io.StringIO`) with .env content, used if
`dotenv_path` is `None`.
verbose: Whether to output a warning the .env file is missing.
verbose: Whether to output a warning the .env file is missing. Ignored
when ``strict`` is ``True`` (strict raises instead of warning).
override: Whether to override the system environment variables with the variables
from the `.env` file.
interpolate: Whether to interpolate variables using POSIX variable expansion.
encoding: Encoding to be used to read the file.
strict: Whether to raise errors instead of silently ignoring them. When
``True``, a ``FileNotFoundError`` is raised if the .env file is not
found and a ``ValueError`` is raised if any line cannot be parsed.
Takes precedence over ``verbose`` — when both are ``True``, the
exception is raised without emitting a warning first.
Returns:
Bool: True if at least one environment variable is set else False

Expand Down Expand Up @@ -426,6 +451,7 @@ def load_dotenv(
interpolate=interpolate,
override=override,
encoding=encoding,
strict=strict,
)
return dotenv.set_as_environment_variables()

Expand All @@ -436,6 +462,7 @@ def dotenv_values(
verbose: bool = False,
interpolate: bool = True,
encoding: Optional[str] = "utf-8",
strict: bool = False,
) -> Dict[str, Optional[str]]:
"""
Parse a .env file and return its content as a dict.
Expand All @@ -447,9 +474,15 @@ def dotenv_values(
Parameters:
dotenv_path: Absolute or relative path to the .env file.
stream: `StringIO` object with .env content, used if `dotenv_path` is `None`.
verbose: Whether to output a warning if the .env file is missing.
verbose: Whether to output a warning if the .env file is missing. Ignored
when ``strict`` is ``True`` (strict raises instead of warning).
interpolate: Whether to interpolate variables using POSIX variable expansion.
encoding: Encoding to be used to read the file.
strict: Whether to raise errors instead of silently ignoring them. When
``True``, a ``FileNotFoundError`` is raised if the .env file is not
found and a ``ValueError`` is raised if any line cannot be parsed.
Takes precedence over ``verbose`` — when both are ``True``, the
exception is raised without emitting a warning first.

If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the
.env file.
Expand All @@ -464,6 +497,7 @@ def dotenv_values(
interpolate=interpolate,
override=True,
encoding=encoding,
strict=strict,
).dict()


Expand Down
111 changes: 111 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,3 +695,114 @@ def test_dotenv_values_file_stream(dotenv_path):
result = dotenv.dotenv_values(stream=f)

assert result == {"a": "b"}


def test_load_dotenv_strict_file_not_found(tmp_path):
nx_path = tmp_path / "nonexistent" / ".env"

with pytest.raises(FileNotFoundError, match="could not find configuration file"):
dotenv.load_dotenv(nx_path, strict=True)


def test_load_dotenv_strict_empty_path_not_found(tmp_path):
os.chdir(tmp_path)

with pytest.raises(FileNotFoundError, match="could not find configuration file"):
dotenv.load_dotenv(str(tmp_path / ".env"), strict=True)


@pytest.mark.skipif(
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
)
@mock.patch.dict(os.environ, {}, clear=True)
def test_load_dotenv_strict_valid_file(dotenv_path):
dotenv_path.write_text("a=b")

result = dotenv.load_dotenv(dotenv_path, strict=True)

assert result is True
assert os.environ == {"a": "b"}


def test_load_dotenv_strict_parse_error(dotenv_path):
dotenv_path.write_text("a: b")

with pytest.raises(
ValueError, match="could not parse statement starting at line 1"
):
dotenv.load_dotenv(dotenv_path, strict=True)


def test_load_dotenv_strict_parse_error_line_number(dotenv_path):
dotenv_path.write_text("valid=ok\ninvalid: line\n")

with pytest.raises(ValueError, match="starting at line 2"):
dotenv.load_dotenv(dotenv_path, strict=True)


def test_load_dotenv_non_strict_file_not_found(tmp_path):
nx_path = tmp_path / ".env"

result = dotenv.load_dotenv(nx_path, strict=False)

assert result is False


def test_load_dotenv_non_strict_parse_error(dotenv_path):
dotenv_path.write_text("a: b")
logger = logging.getLogger("dotenv.main")

with mock.patch.object(logger, "warning") as mock_warning:
result = dotenv.load_dotenv(dotenv_path, strict=False)

assert result is False
mock_warning.assert_called_once()


def test_dotenv_values_strict_file_not_found(tmp_path):
nx_path = tmp_path / ".env"

with pytest.raises(FileNotFoundError, match="could not find configuration file"):
dotenv.dotenv_values(nx_path, strict=True)


def test_dotenv_values_strict_valid_file(dotenv_path):
dotenv_path.write_text("a=b\nc=d")

result = dotenv.dotenv_values(dotenv_path, strict=True)

assert result == {"a": "b", "c": "d"}


def test_dotenv_values_strict_parse_error(dotenv_path):
dotenv_path.write_text("good=value\nbad: line")

with pytest.raises(
ValueError, match="could not parse statement starting at line 2"
):
dotenv.dotenv_values(dotenv_path, strict=True)


def test_dotenv_values_strict_with_stream():
stream = io.StringIO("a=b")

result = dotenv.dotenv_values(stream=stream, strict=True)

assert result == {"a": "b"}


def test_dotenv_values_strict_stream_parse_error():
stream = io.StringIO("bad: line")

with pytest.raises(
ValueError, match="could not parse statement starting at line 1"
):
dotenv.dotenv_values(stream=stream, strict=True)


def test_load_dotenv_strict_default_is_false(dotenv_path):
dotenv_path.write_text("a: b")

result = dotenv.load_dotenv(dotenv_path)

assert result is False