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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ Source = "https://github.com/theskumar/python-dotenv"
cli = [
"click>=5.0",
]
native = [
"fast-dotenv-rs-backend>=0.1.1,<0.2; platform_python_implementation != 'PyPy'",
]

[project.scripts]
dotenv = "dotenv.__main__:cli"
Expand Down
80 changes: 80 additions & 0 deletions src/dotenv/_native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Optional parser bridge for the backend-only distribution.

``fast-dotenv-rs-backend`` is deliberately separate from the upstream
``dotenv`` package. Its only adapter contract is a ``parse_bindings``
function returning lossless records in the order
``(key, value, original_string, original_line, error)`` and the versioned
contract markers ``BACKEND_CONTRACT`` and ``BACKEND_CONTRACT_VERSION``.

The Python parser is the normal path. It is selected only when the backend
package is absent (or when running on PyPy). Once a backend is imported, an
exception or contract violation is raised instead of being hidden by a
fallback; this keeps native semantic mismatches observable.
"""

from importlib import import_module
from platform import python_implementation
from typing import Iterator, Optional, Tuple

BindingRecord = Tuple[Optional[str], Optional[str], str, int, bool]

_BACKEND_MODULE = "fast_dotenv_rs_backend"
_BACKEND_DISTRIBUTION = "fast-dotenv-rs-backend"
_BACKEND_CONTRACT = "fast-dotenv-rs.backend.binding"
_BACKEND_CONTRACT_VERSION = 1


class NativeBackendContractError(RuntimeError):
"""The selected backend did not satisfy the parser adapter contract."""


def _normalize_record(record: object) -> BindingRecord:
if not isinstance(record, tuple) or len(record) != 5:
raise NativeBackendContractError(
"native parse_bindings record must be a five-item tuple"
)

key, value, original, line, error = record
if key is not None and not isinstance(key, str):
raise NativeBackendContractError("native binding key must be str or None")
if value is not None and not isinstance(value, str):
raise NativeBackendContractError("native binding value must be str or None")
if not isinstance(original, str) or type(line) is not int or line < 1:
raise NativeBackendContractError(
"native binding original must be str and line must be a positive int"
)
if type(error) is not bool:
raise NativeBackendContractError("native binding error must be bool")

return key, value, original, line, error


def parse_bindings(text: str) -> Optional[Iterator[BindingRecord]]:
"""Return validated native records, or ``None`` when no backend is present."""
if python_implementation() == "PyPy":
return None

try:
backend = import_module(_BACKEND_MODULE)
except ModuleNotFoundError as error:
if error.name == _BACKEND_MODULE:
return None
raise

if (
getattr(backend, "BACKEND_CONTRACT", None) != _BACKEND_CONTRACT
or type(getattr(backend, "BACKEND_CONTRACT_VERSION", None)) is not int
or backend.BACKEND_CONTRACT_VERSION != _BACKEND_CONTRACT_VERSION
):
raise NativeBackendContractError(
f"{_BACKEND_DISTRIBUTION} does not expose the supported parser contract"
)

parser = getattr(backend, "parse_bindings", None)
if not callable(parser):
raise NativeBackendContractError(
f"{_BACKEND_DISTRIBUTION} does not expose parse_bindings"
)

records = [_normalize_record(record) for record in parser(text)]
return iter(records)
14 changes: 14 additions & 0 deletions src/dotenv/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
Sequence,
)

from ._native import parse_bindings as _parse_native_bindings


def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]:
return re.compile(string, re.UNICODE | extra_flags)
Expand Down Expand Up @@ -187,5 +189,17 @@ def parse_binding(reader: Reader) -> Binding:

def parse_stream(stream: IO[str]) -> Iterator[Binding]:
reader = Reader(stream)

native_bindings = _parse_native_bindings(reader.string)
if native_bindings is not None:
for key, value, original, line, error in native_bindings:
yield Binding(
key=key,
value=value,
original=Original(string=original, line=line),
error=error,
)
return

while reader.has_next():
yield parse_binding(reader)
215 changes: 215 additions & 0 deletions tests/test_native_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import io
import sys
from pathlib import Path
from types import ModuleType

import pytest

import dotenv
import dotenv._native as native
import dotenv.parser as parser_module
from dotenv.parser import Binding, Original, parse_stream

native_only = pytest.mark.skipif(
sys.implementation.name == "pypy",
reason="native backend contract tests require CPython",
)


def _python_bindings(text):
return list(parse_stream(io.StringIO(text)))


def _mark_backend(backend):
backend.BACKEND_CONTRACT = native._BACKEND_CONTRACT
backend.BACKEND_CONTRACT_VERSION = native._BACKEND_CONTRACT_VERSION
return backend


def test_missing_backend_falls_back_to_python_parser(monkeypatch):
def missing_backend(module_name):
raise ModuleNotFoundError(module_name, name=module_name)

monkeypatch.setattr(native, "import_module", missing_backend)

assert _python_bindings("a=b\n") == [
Binding(
key="a",
value="b",
original=Original(string="a=b\n", line=1),
error=False,
)
]


@native_only
def test_backend_exception_is_not_hidden_by_python_fallback(monkeypatch):
class BrokenBackend:
@staticmethod
def parse_bindings(text):
raise RuntimeError("backend semantic failure")

monkeypatch.setattr(native, "import_module", lambda _: _mark_backend(BrokenBackend))

with pytest.raises(RuntimeError, match="backend semantic failure"):
_python_bindings("a=b\n")


@pytest.mark.parametrize(
"records",
[
[("a", "b", "a=b", 1)],
[("a", "b", "a=b", 1, "false")],
[("a", "b", "a=b", 0, False)],
],
)
@native_only
def test_invalid_backend_records_are_hard_contract_errors(monkeypatch, records):
class InvalidBackend:
@staticmethod
def parse_bindings(text):
return records

monkeypatch.setattr(
native, "import_module", lambda _: _mark_backend(InvalidBackend)
)

with pytest.raises(native.NativeBackendContractError):
_python_bindings("a=b\n")


@native_only
def test_backend_import_dependency_error_is_not_hidden(monkeypatch):
def broken_import(module_name):
raise ModuleNotFoundError("backend dependency missing", name="dependency")

monkeypatch.setattr(native, "import_module", broken_import)

with pytest.raises(ModuleNotFoundError, match="backend dependency missing"):
_python_bindings("a=b\n")


def test_pypy_never_attempts_native_import(monkeypatch):
monkeypatch.setattr(native, "python_implementation", lambda: "PyPy")
monkeypatch.setattr(
native,
"import_module",
lambda _: pytest.fail("PyPy must not import a native backend"),
)

assert _python_bindings("a=b\n") == [
Binding(
key="a",
value="b",
original=Original(string="a=b\n", line=1),
error=False,
)
]


@native_only
def test_backend_contract_markers_are_required(monkeypatch):
class UnversionedBackend:
@staticmethod
def parse_bindings(text):
return [("a", "b", "a=b\n", 1, False)]

monkeypatch.setattr(native, "import_module", lambda _: UnversionedBackend)

with pytest.raises(
native.NativeBackendContractError, match="supported parser contract"
):
_python_bindings("a=b\n")


@native_only
def test_backend_contract_marker_mismatch_is_not_hidden(monkeypatch):
class WrongContractBackend:
BACKEND_CONTRACT = "wrong.contract"
BACKEND_CONTRACT_VERSION = 99

@staticmethod
def parse_bindings(text):
return [("a", "b", "a=b\n", 1, False)]

monkeypatch.setattr(native, "import_module", lambda _: WrongContractBackend)

with pytest.raises(
native.NativeBackendContractError, match="supported parser contract"
):
_python_bindings("a=b\n")


@native_only
def test_valid_backend_records_are_adapted_at_parser_boundary(monkeypatch):
calls = []

class Backend:
@staticmethod
def parse_bindings(text):
calls.append(text)
return [("a", "b", "a=b\n", 1, False)]

monkeypatch.setattr(native, "import_module", lambda _: _mark_backend(Backend))
monkeypatch.setattr(
parser_module,
"parse_binding",
lambda _: pytest.fail("native path must not duplicate Python parsing"),
)

result = list(parse_stream(io.StringIO("\ufeffa=b\n")))

assert calls == ["a=b\n"]
assert result == [
Binding(
key="a",
value="b",
original=Original(string="a=b\n", line=1),
error=False,
)
]
assert type(result[0]) is Binding
assert type(result[0].original) is Original


@native_only
def test_legacy_dotenv_core_is_not_used(monkeypatch):
calls = []

def missing_backend(module_name):
calls.append(module_name)
raise ModuleNotFoundError(module_name, name=module_name)

monkeypatch.setattr(native, "import_module", missing_backend)
assert _python_bindings("a=b\n")[0].value == "b"
assert calls == ["fast_dotenv_rs_backend"]


@native_only
def test_extension_only_backend_can_coexist_with_upstream_package(monkeypatch):
backend = ModuleType(native._BACKEND_MODULE)
_mark_backend(backend)
backend.__dict__["parse_bindings"] = lambda text: [("a", "b", "a=b", 1, False)]
monkeypatch.setitem(sys.modules, native._BACKEND_MODULE, backend)

result = list(parse_stream(io.StringIO("a=b")))

assert Path(dotenv.__file__).name == "__init__.py"
assert dotenv.__all__ == [
"get_cli_string",
"load_dotenv",
"dotenv_values",
"get_key",
"set_key",
"unset_key",
"find_dotenv",
"load_ipython_extension",
]
assert result == [
Binding(
key="a",
value="b",
original=Original(string="a=b", line=1),
error=False,
)
]