Skip to content

Commit cecb71a

Browse files
committed
Add on_bind callback to dotenv parser for immediate env var visibility
Command substitutions in .env files now see variables defined earlier in the same file by setting os.environ as each binding is parsed.
1 parent d28f56c commit cecb71a

File tree

2 files changed

+96
-21
lines changed

2 files changed

+96
-21
lines changed

plain/plain/utils/dotenv.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import os
1717
import re
1818
import subprocess
19+
from collections.abc import Callable
1920
from pathlib import Path
2021

2122
__all__ = ["load_dotenv", "parse_dotenv"]
@@ -46,13 +47,16 @@ def load_dotenv(
4647
if not path.exists():
4748
return False
4849

50+
content = path.read_text(encoding="utf-8")
51+
4952
# Skip command execution for keys that already exist (unless override)
5053
skip_commands_for = None if override else set(os.environ.keys())
51-
env_vars = _parse_dotenv_internal(path, skip_commands_for=skip_commands_for)
52-
for key, value in env_vars.items():
54+
55+
def on_bind(key: str, value: str) -> None:
5356
if override or key not in os.environ:
5457
os.environ[key] = value
5558

59+
_parse_content(content, skip_commands_for=skip_commands_for, on_bind=on_bind)
5660
return True
5761

5862

@@ -62,27 +66,14 @@ def parse_dotenv(filepath: str | Path) -> dict[str, str]:
6266
6367
Does not modify os.environ. Supports multiline values in quoted strings.
6468
"""
65-
return _parse_dotenv_internal(filepath, skip_commands_for=None)
66-
67-
68-
def _parse_dotenv_internal(
69-
filepath: str | Path, skip_commands_for: set[str] | None = None
70-
) -> dict[str, str]:
71-
"""
72-
Internal parser that can skip command execution for certain keys.
73-
74-
Args:
75-
filepath: Path to the .env file
76-
skip_commands_for: If provided, skip command substitution for keys in this set
77-
and use os.environ value instead
78-
"""
79-
path = Path(filepath)
80-
content = path.read_text(encoding="utf-8")
81-
return _parse_content(content, skip_commands_for=skip_commands_for)
69+
content = Path(filepath).read_text(encoding="utf-8")
70+
return _parse_content(content)
8271

8372

8473
def _parse_content(
85-
content: str, skip_commands_for: set[str] | None = None
74+
content: str,
75+
skip_commands_for: set[str] | None = None,
76+
on_bind: Callable[[str, str], None] | None = None,
8677
) -> dict[str, str]:
8778
"""Parse .env file content and return key-value pairs."""
8879
result: dict[str, str] = {}
@@ -107,6 +98,8 @@ def _parse_content(
10798
if parsed:
10899
key, value, new_pos = parsed
109100
result[key] = value
101+
if on_bind:
102+
on_bind(key, value)
110103
pos = new_pos
111104
else:
112105
# Skip to next line on parse failure
@@ -136,7 +129,7 @@ def _parse_binding(
136129
length = len(content)
137130

138131
# Skip optional 'export ' prefix
139-
if content[pos:].startswith("export "):
132+
if content[pos : pos + 7] == "export ":
140133
pos += 7
141134
while pos < length and content[pos] in " \t":
142135
pos += 1

plain/tests/test_dotenv.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import os
2+
3+
import pytest
4+
5+
from plain.utils.dotenv import load_dotenv, parse_dotenv
6+
7+
8+
@pytest.fixture
9+
def env_file(tmp_path):
10+
"""Helper to create a .env file with given content."""
11+
12+
def _create(content):
13+
path = tmp_path / ".env"
14+
path.write_text(content)
15+
return path
16+
17+
return _create
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def clean_env():
22+
"""Remove test keys from os.environ after each test."""
23+
yield
24+
for key in list(os.environ):
25+
if key.startswith("TEST_"):
26+
del os.environ[key]
27+
28+
29+
def test_command_substitution_sees_earlier_vars(env_file):
30+
"""Command substitution should see variables defined earlier in the same .env file."""
31+
path = env_file("TEST_TOKEN=hello\nTEST_RESULT=$(echo $TEST_TOKEN)\n")
32+
load_dotenv(path)
33+
assert os.environ["TEST_TOKEN"] == "hello"
34+
assert os.environ["TEST_RESULT"] == "hello"
35+
36+
37+
def test_command_substitution_chained(env_file):
38+
"""Multiple command substitutions can chain through os.environ."""
39+
path = env_file(
40+
"TEST_A=first\nTEST_B=$(echo $TEST_A)-second\nTEST_C=$(echo $TEST_B)\n"
41+
)
42+
load_dotenv(path)
43+
assert os.environ["TEST_A"] == "first"
44+
assert os.environ["TEST_B"] == "first-second"
45+
assert os.environ["TEST_C"] == "first-second"
46+
47+
48+
def test_parse_dotenv_no_environ_side_effects(env_file):
49+
"""parse_dotenv should not modify os.environ."""
50+
path = env_file("TEST_PARSE_ONLY=value\n")
51+
result = parse_dotenv(path)
52+
assert result == {"TEST_PARSE_ONLY": "value"}
53+
assert "TEST_PARSE_ONLY" not in os.environ
54+
55+
56+
def test_load_dotenv_no_override_by_default(env_file):
57+
"""Existing env vars should not be overridden by default."""
58+
os.environ["TEST_EXISTING"] = "original"
59+
path = env_file("TEST_EXISTING=new_value\n")
60+
load_dotenv(path)
61+
assert os.environ["TEST_EXISTING"] == "original"
62+
63+
64+
def test_load_dotenv_override(env_file):
65+
"""With override=True, existing env vars should be replaced."""
66+
os.environ["TEST_EXISTING"] = "original"
67+
path = env_file("TEST_EXISTING=new_value\n")
68+
load_dotenv(path, override=True)
69+
assert os.environ["TEST_EXISTING"] == "new_value"
70+
71+
72+
def test_load_dotenv_missing_file(tmp_path):
73+
"""load_dotenv should return False for a missing file."""
74+
result = load_dotenv(tmp_path / "nonexistent.env")
75+
assert result is False
76+
77+
78+
def test_load_dotenv_returns_true(env_file):
79+
"""load_dotenv should return True when file exists."""
80+
path = env_file("TEST_KEY=value\n")
81+
result = load_dotenv(path)
82+
assert result is True

0 commit comments

Comments
 (0)