Skip to content

Commit 669d01e

Browse files
committed
feat: Support for default values in .env.example templates using {{ VARIABLE | default_value }} syntax
1 parent 6e5ce94 commit 669d01e

File tree

7 files changed

+197
-11
lines changed

7 files changed

+197
-11
lines changed

.serena/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/cache

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Support for default values in `.env.example` templates using `{{ VARIABLE | default_value }}` syntax
12+
- Works with environment variables: `{{ API_KEY | dev-key }}` - uses default when variable is not set
13+
- Works with function placeholders: `{{ branch() | main }}` - uses default when branch name is not provided
14+
- Supports empty string as default: `{{ VAR | }}` - uses empty string when variable is not set
15+
- Whitespace around default values is automatically trimmed
16+
- Environment variables always override default values when set
1117

1218
### Changed
1319

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Backend service
2-
JWT_SECRET={{ JWT_SECRET }}
2+
JWT_SECRET={{ JWT_SECRET | dev-secret-key-change-in-production }}
33
API_PORT={{ auto_port() }}
4-
DOCKER_NETWORK={{ branch() }}-sprout-nw
4+
DOCKER_NETWORK={{ branch() | main }}-sprout-nw
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Frontend service
2-
REACT_APP_API_KEY={{ REACT_APP_API_KEY }}
2+
REACT_APP_API_KEY={{ REACT_APP_API_KEY | dev-api-key }}
33
FRONTEND_PORT={{ auto_port() }}
4-
DOCKER_NETWORK={{ branch() }}-sprout-nw
4+
DOCKER_NETWORK={{ branch() | main }}-sprout-nw

src/sprout/utils.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,26 +155,56 @@ def parse_env_template(
155155
file_ports.update(used_ports)
156156

157157
for line in content.splitlines():
158-
# Process {{ auto_port() }} placeholders
158+
# Process {{ auto_port() | default }} placeholders
159159
def replace_auto_port(match: re.Match[str]) -> str:
160+
# Extract default value if present (group 1)
161+
default_value = match.group(1)
162+
if default_value is not None:
163+
default_value = default_value.strip()
164+
165+
# Generate available port
160166
port = find_available_port()
161167
while port in file_ports:
162168
port = find_available_port()
163169
file_ports.add(port)
164170
return str(port)
165171

166-
line = re.sub(r"{{\s*auto_port\(\)\s*}}", replace_auto_port, line)
172+
line = re.sub(r"{{\s*auto_port\(\)(?:\s*\|\s*([^}]*))?\s*}}", replace_auto_port, line)
173+
174+
# Process {{ branch() | default }} placeholders
175+
def replace_branch(match: re.Match[str]) -> str:
176+
# Extract default value if present (group 1)
177+
default_value = match.group(1)
178+
if default_value is not None:
179+
default_value = default_value.strip()
180+
181+
# Use branch_name if provided, otherwise use default
182+
if branch_name:
183+
return branch_name
184+
elif default_value is not None:
185+
return default_value
186+
else:
187+
# No branch name and no default - keep placeholder unchanged
188+
return match.group(0)
167189

168-
# Process {{ branch() }} placeholders
169-
if branch_name:
170-
line = re.sub(r"{{\s*branch\(\)\s*}}", branch_name, line)
190+
line = re.sub(r"{{\s*branch\(\)(?:\s*\|\s*([^}]*))?\s*}}", replace_branch, line)
171191

172-
# Process {{ VARIABLE }} placeholders
192+
# Process {{ VARIABLE | default }} placeholders
173193
def replace_variable(match: re.Match[str]) -> str:
174194
var_name = match.group(1).strip()
195+
# Extract default value if present (group 2)
196+
default_value = match.group(2)
197+
if default_value is not None:
198+
default_value = default_value.strip()
199+
175200
# Check environment variable first
176201
value = os.environ.get(var_name)
177202
if value is None:
203+
# If default value is provided, use it
204+
if default_value is not None:
205+
return default_value
206+
207+
# No default value - prompt user for value
178208
# Create a relative path for display
179209
try:
180210
display_path = template_path.relative_to(Path.cwd())
@@ -196,7 +226,8 @@ def replace_variable(match: re.Match[str]) -> str:
196226
return value
197227

198228
# Only match variables that don't look like function calls (no parentheses)
199-
line = re.sub(r"{{\s*([^}()]+)\s*}}", replace_variable, line)
229+
# Now also captures optional | default_value
230+
line = re.sub(r"{{\s*([^}()]+?)(?:\s*\|\s*([^}]*))?\s*}}", replace_variable, line)
200231

201232
lines.append(line)
202233

tests/test_integration.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,61 @@ def test_branch_placeholder(self, git_repo, monkeypatch):
239239
assert "PORT=" in env_content # Should have a port number
240240
assert "COMPOSE_VAR=${BRANCH_NAME}" in env_content # Docker syntax preserved
241241

242+
def test_default_values(self, git_repo, monkeypatch):
243+
"""Test that default values work correctly in .env.example templates."""
244+
git_repo, default_branch = git_repo
245+
monkeypatch.chdir(git_repo)
246+
247+
# Set only some environment variables
248+
monkeypatch.setenv("SET_VAR", "from_environment")
249+
# UNSET_VAR is not set - should use default
250+
251+
# Create .env.example with default values
252+
env_example = git_repo / ".env.example"
253+
env_example.write_text(
254+
"# Test default values\n"
255+
"SET_VAR={{ SET_VAR | default_value }}\n"
256+
"UNSET_VAR={{ UNSET_VAR | localhost }}\n"
257+
"EMPTY_VAR={{ EMPTY_VAR | }}\n"
258+
"PORT={{ auto_port() | 8080 }}\n"
259+
"BRANCH={{ branch() | develop }}\n"
260+
"NO_DEFAULT={{ NO_DEFAULT }}\n" # Will be prompted
261+
)
262+
263+
# Add to git
264+
subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True)
265+
subprocess.run(
266+
["git", "commit", "-m", "Add .env.example with defaults"],
267+
cwd=git_repo,
268+
check=True,
269+
)
270+
271+
# Set NO_DEFAULT to avoid prompt
272+
monkeypatch.setenv("NO_DEFAULT", "prompted_value")
273+
274+
# Create worktree
275+
branch_name = "feature-test"
276+
result = runner.invoke(app, ["create", branch_name])
277+
assert result.exit_code == 0
278+
279+
# Verify .env was created with correct values
280+
env_file = git_repo / ".sprout" / branch_name / ".env"
281+
env_content = env_file.read_text()
282+
283+
# Check that environment variable overrides default
284+
assert "SET_VAR=from_environment" in env_content
285+
# Check that default is used when variable is not set
286+
assert "UNSET_VAR=localhost" in env_content
287+
# Check empty string default
288+
assert "EMPTY_VAR=" in env_content or "EMPTY_VAR=\n" in env_content
289+
# Check that auto_port generates a port (default not used)
290+
assert "PORT=" in env_content
291+
assert "PORT=8080" not in env_content # Should be a generated port
292+
# Check that branch name is used (not default)
293+
assert f"BRANCH={branch_name}" in env_content
294+
# Check prompted value
295+
assert "NO_DEFAULT=prompted_value" in env_content
296+
242297
def test_error_cases(self, git_repo, monkeypatch, tmp_path):
243298
"""Test various error conditions."""
244299
git_repo, default_branch = git_repo

tests/test_utils.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,99 @@ def test_parse_env_template_mixed_placeholders(self, tmp_path, mocker):
220220
"COMPOSE_VAR=${COMPOSE_VAR}"
221221
)
222222

223+
def test_parse_env_template_variable_with_default(self, tmp_path, mocker):
224+
"""Test parsing {{ VARIABLE | default }} when variable is not set."""
225+
# Ensure the variable is not in environment
226+
mocker.patch.dict(os.environ, {}, clear=False)
227+
if "TEST_VAR" in os.environ:
228+
del os.environ["TEST_VAR"]
229+
230+
template = tmp_path / ".env.example"
231+
template.write_text("TEST_VAR={{ TEST_VAR | localhost }}")
232+
233+
result = parse_env_template(template)
234+
assert result == "TEST_VAR=localhost"
235+
236+
def test_parse_env_template_variable_with_default_env_set(self, tmp_path, mocker):
237+
"""Test that environment variable overrides default value."""
238+
mocker.patch.dict(os.environ, {"TEST_VAR": "custom_value"})
239+
240+
template = tmp_path / ".env.example"
241+
template.write_text("TEST_VAR={{ TEST_VAR | localhost }}")
242+
243+
result = parse_env_template(template)
244+
assert result == "TEST_VAR=custom_value"
245+
246+
def test_parse_env_template_variable_with_empty_default(self, tmp_path, mocker):
247+
"""Test parsing {{ VARIABLE | }} with empty string as default."""
248+
mocker.patch.dict(os.environ, {}, clear=False)
249+
if "EMPTY_VAR" in os.environ:
250+
del os.environ["EMPTY_VAR"]
251+
252+
template = tmp_path / ".env.example"
253+
template.write_text("EMPTY_VAR={{ EMPTY_VAR | }}")
254+
255+
result = parse_env_template(template)
256+
assert result == "EMPTY_VAR="
257+
258+
def test_parse_env_template_variable_whitespace_trimming(self, tmp_path, mocker):
259+
"""Test that whitespace is trimmed from default values."""
260+
mocker.patch.dict(os.environ, {}, clear=False)
261+
if "TEST_VAR" in os.environ:
262+
del os.environ["TEST_VAR"]
263+
264+
template = tmp_path / ".env.example"
265+
template.write_text("TEST_VAR={{ TEST_VAR | localhost }}")
266+
267+
result = parse_env_template(template)
268+
assert result == "TEST_VAR=localhost"
269+
270+
def test_parse_env_template_branch_with_default(self, tmp_path):
271+
"""Test parsing {{ branch() | default }} when branch_name is None."""
272+
template = tmp_path / ".env.example"
273+
template.write_text("BRANCH={{ branch() | main }}")
274+
275+
result = parse_env_template(template, branch_name=None)
276+
assert result == "BRANCH=main"
277+
278+
def test_parse_env_template_branch_with_default_branch_set(self, tmp_path):
279+
"""Test that branch_name overrides default value."""
280+
template = tmp_path / ".env.example"
281+
template.write_text("BRANCH={{ branch() | main }}")
282+
283+
result = parse_env_template(template, branch_name="feature-xyz")
284+
assert result == "BRANCH=feature-xyz"
285+
286+
def test_parse_env_template_auto_port_with_default(self, tmp_path, mocker):
287+
"""Test {{ auto_port() | default }} - default is captured but not used."""
288+
mocker.patch("sprout.utils.find_available_port", return_value=9000)
289+
290+
template = tmp_path / ".env.example"
291+
template.write_text("PORT={{ auto_port() | 8080 }}")
292+
293+
# auto_port() always generates a port, so default is not used
294+
result = parse_env_template(template)
295+
assert result == "PORT=9000"
296+
297+
def test_parse_env_template_mixed_with_defaults(self, tmp_path, mocker):
298+
"""Test parsing mixed placeholders with default values."""
299+
mocker.patch("sprout.utils.find_available_port", return_value=8080)
300+
mocker.patch.dict(os.environ, {"SET_VAR": "from_env"}, clear=False)
301+
if "UNSET_VAR" in os.environ:
302+
del os.environ["UNSET_VAR"]
303+
304+
template = tmp_path / ".env.example"
305+
template.write_text(
306+
"SET_VAR={{ SET_VAR | default1 }}\n"
307+
"UNSET_VAR={{ UNSET_VAR | default2 }}\n"
308+
"PORT={{ auto_port() | 3000 }}\n"
309+
"BRANCH={{ branch() | develop }}\n"
310+
"EMPTY={{ EMPTY | }}"
311+
)
312+
313+
result = parse_env_template(template, branch_name=None)
314+
assert result == ("SET_VAR=from_env\nUNSET_VAR=default2\nPORT=8080\nBRANCH=develop\nEMPTY=")
315+
223316
def test_parse_env_template_file_not_found(self, tmp_path):
224317
"""Test error when template file doesn't exist."""
225318
template = tmp_path / "nonexistent.env"

0 commit comments

Comments
 (0)