sprout-cli 0.7.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/PKG-INFO +5 -4
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/README.md +2 -1
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/pyproject.toml +1 -1
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/__init__.py +1 -1
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/utils.py +102 -25
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/tests/test_multi_env.py +23 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/tests/test_utils.py +183 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/.gitignore +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/LICENSE +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/__main__.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/cli.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/commands/__init__.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/commands/create.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/commands/ls.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/commands/path.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/commands/rm.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/exceptions.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/src/sprout/types.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/tests/__init__.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/tests/test_commands.py +0 -0
- {sprout_cli-0.7.0 → sprout_cli-0.9.0}/tests/test_integration.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sprout-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: CLI tool to automate git worktree and Docker Compose development workflows
|
|
5
|
-
Author:
|
|
5
|
+
Author: AgenticSec
|
|
6
6
|
License: MIT
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -47,7 +47,7 @@ pip install sprout-cli
|
|
|
47
47
|
For development:
|
|
48
48
|
```bash
|
|
49
49
|
# Clone the repository
|
|
50
|
-
git clone https://github.com/
|
|
50
|
+
git clone https://github.com/AgenticSec/sprout.git
|
|
51
51
|
cd sprout
|
|
52
52
|
|
|
53
53
|
# Install in development mode
|
|
@@ -196,6 +196,7 @@ sprout supports three types of placeholders in `.env.example`:
|
|
|
196
196
|
- Avoids conflicts across ALL services in ALL sprout environments
|
|
197
197
|
- Checks system port availability
|
|
198
198
|
- Ensures global uniqueness even in monorepo setups
|
|
199
|
+
- Collects used ports from the `.env` files of every worktree, skipping dependency and cache directories (`node_modules`, `.venv`, `.git`, ...)
|
|
199
200
|
|
|
200
201
|
3. **Branch Name**: `{{ branch() }}`
|
|
201
202
|
- Replaced with the current branch/subtree name
|
|
@@ -19,7 +19,7 @@ pip install sprout-cli
|
|
|
19
19
|
For development:
|
|
20
20
|
```bash
|
|
21
21
|
# Clone the repository
|
|
22
|
-
git clone https://github.com/
|
|
22
|
+
git clone https://github.com/AgenticSec/sprout.git
|
|
23
23
|
cd sprout
|
|
24
24
|
|
|
25
25
|
# Install in development mode
|
|
@@ -168,6 +168,7 @@ sprout supports three types of placeholders in `.env.example`:
|
|
|
168
168
|
- Avoids conflicts across ALL services in ALL sprout environments
|
|
169
169
|
- Checks system port availability
|
|
170
170
|
- Ensures global uniqueness even in monorepo setups
|
|
171
|
+
- Collects used ports from the `.env` files of every worktree, skipping dependency and cache directories (`node_modules`, `.venv`, `.git`, ...)
|
|
171
172
|
|
|
172
173
|
3. **Branch Name**: `{{ branch() }}`
|
|
173
174
|
- Replaced with the current branch/subtree name
|
|
@@ -5,6 +5,7 @@ import random
|
|
|
5
5
|
import re
|
|
6
6
|
import socket
|
|
7
7
|
import subprocess
|
|
8
|
+
from collections.abc import Iterator
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import TypeAlias
|
|
@@ -19,6 +20,27 @@ from sprout.types import BranchName, WorktreeInfo
|
|
|
19
20
|
PortNumber: TypeAlias = int
|
|
20
21
|
PortSet: TypeAlias = set[PortNumber]
|
|
21
22
|
|
|
23
|
+
# Directory names skipped when scanning worktrees for .env files. These hold
|
|
24
|
+
# dependencies, caches and VCS internals, never a .env that sprout generated,
|
|
25
|
+
# but they can each contain tens of thousands of files. Descending into them
|
|
26
|
+
# turns a port scan into a multi-second walk on a real workspace.
|
|
27
|
+
SCAN_EXCLUDED_DIRS: frozenset[str] = frozenset(
|
|
28
|
+
{
|
|
29
|
+
".direnv",
|
|
30
|
+
".git",
|
|
31
|
+
".mypy_cache",
|
|
32
|
+
".next",
|
|
33
|
+
".pytest_cache",
|
|
34
|
+
".ruff_cache",
|
|
35
|
+
".terraform",
|
|
36
|
+
".tox",
|
|
37
|
+
".venv",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
"node_modules",
|
|
40
|
+
"venv",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
22
44
|
console = Console()
|
|
23
45
|
|
|
24
46
|
|
|
@@ -75,6 +97,45 @@ def ensure_sprout_dir() -> Path:
|
|
|
75
97
|
return sprout_dir
|
|
76
98
|
|
|
77
99
|
|
|
100
|
+
def _is_worktree_root(path: Path) -> bool:
|
|
101
|
+
"""Check whether a directory is a git worktree root.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Directory to inspect.
|
|
105
|
+
"""
|
|
106
|
+
# git worktree add always leaves a .git entry: a file in a linked worktree,
|
|
107
|
+
# a directory in a main checkout.
|
|
108
|
+
return (path / ".git").exists()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def iter_env_files(root: Path) -> Iterator[Path]:
|
|
112
|
+
"""Yield .env files under root, skipping dependency and cache directories.
|
|
113
|
+
|
|
114
|
+
An excluded name is still walked when the directory is a worktree root:
|
|
115
|
+
a branch named "venv" or "fix/venv" puts a real worktree behind an excluded
|
|
116
|
+
name, and skipping it would hand its ports out to the next worktree.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
root: Directory to walk.
|
|
120
|
+
|
|
121
|
+
Yields:
|
|
122
|
+
Paths of the .env files found under root.
|
|
123
|
+
"""
|
|
124
|
+
for dir_path, dir_names, file_names in os.walk(root):
|
|
125
|
+
# Prune in place so os.walk does not descend into excluded directories
|
|
126
|
+
dir_names[:] = [
|
|
127
|
+
name
|
|
128
|
+
for name in dir_names
|
|
129
|
+
if name not in SCAN_EXCLUDED_DIRS or _is_worktree_root(Path(dir_path) / name)
|
|
130
|
+
]
|
|
131
|
+
for file_name in file_names:
|
|
132
|
+
if file_name.endswith(".env"):
|
|
133
|
+
env_file = Path(dir_path) / file_name
|
|
134
|
+
# Only regular files: a fifo named .env would block read_text()
|
|
135
|
+
if env_file.is_file():
|
|
136
|
+
yield env_file
|
|
137
|
+
|
|
138
|
+
|
|
78
139
|
def get_used_ports() -> PortSet:
|
|
79
140
|
"""Get all ports currently used by sprout worktrees."""
|
|
80
141
|
used_ports: PortSet = set()
|
|
@@ -84,18 +145,17 @@ def get_used_ports() -> PortSet:
|
|
|
84
145
|
return used_ports
|
|
85
146
|
|
|
86
147
|
# Scan all .env files recursively in .sprout/
|
|
87
|
-
for env_file in sprout_dir
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
continue
|
|
148
|
+
for env_file in iter_env_files(sprout_dir):
|
|
149
|
+
try:
|
|
150
|
+
content = env_file.read_text()
|
|
151
|
+
# Find all port assignments (e.g., PORT=8080)
|
|
152
|
+
port_matches = re.findall(r"=(\d{4,5})\b", content)
|
|
153
|
+
for port_str in port_matches:
|
|
154
|
+
port = int(port_str)
|
|
155
|
+
if 1024 <= port <= 65535:
|
|
156
|
+
used_ports.add(port)
|
|
157
|
+
except (OSError, ValueError):
|
|
158
|
+
continue
|
|
99
159
|
|
|
100
160
|
return used_ports
|
|
101
161
|
|
|
@@ -110,9 +170,18 @@ def is_port_available(port: PortNumber) -> bool:
|
|
|
110
170
|
return False
|
|
111
171
|
|
|
112
172
|
|
|
113
|
-
def find_available_port() -> PortNumber:
|
|
114
|
-
"""Find an available port that's not used by sprout or system.
|
|
115
|
-
|
|
173
|
+
def find_available_port(used_ports: PortSet | None = None) -> PortNumber:
|
|
174
|
+
"""Find an available port that's not used by sprout or system.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
used_ports: Ports to treat as taken, replacing the workspace scan rather
|
|
178
|
+
than adding to it. Scanning every worktree is the expensive part of
|
|
179
|
+
this call, so callers that allocate several ports in a row should
|
|
180
|
+
scan once and pass the result instead of paying for a fresh walk per
|
|
181
|
+
port. An empty set means nothing is taken; pass None to scan.
|
|
182
|
+
"""
|
|
183
|
+
if used_ports is None:
|
|
184
|
+
used_ports = get_used_ports()
|
|
116
185
|
max_attempts = 1000
|
|
117
186
|
|
|
118
187
|
for _ in range(max_attempts):
|
|
@@ -136,7 +205,11 @@ def parse_env_template(
|
|
|
136
205
|
Args:
|
|
137
206
|
template_path: Path to the .env.example template file
|
|
138
207
|
silent: If True, use stderr for prompts to keep stdout clean
|
|
139
|
-
used_ports:
|
|
208
|
+
used_ports: Ports already in use across the workspace. The set replaces
|
|
209
|
+
the workspace scan rather than adding to it, so passing a partial
|
|
210
|
+
set silently gives up collision detection against the other
|
|
211
|
+
worktrees. An empty set means nothing is taken; omit it to have this
|
|
212
|
+
function scan once, and only if the template asks for a port.
|
|
140
213
|
branch_name: Branch name to use for {{ branch() }} placeholders
|
|
141
214
|
"""
|
|
142
215
|
if not template_path.exists():
|
|
@@ -148,11 +221,12 @@ def parse_env_template(
|
|
|
148
221
|
raise SproutError(f"Failed to read .env.example: {e}") from e
|
|
149
222
|
|
|
150
223
|
lines: list[str] = []
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
224
|
+
# Ports that {{ auto_port() }} must avoid: the ones already taken across the
|
|
225
|
+
# workspace plus the ones handed out earlier in this file. Left as None
|
|
226
|
+
# until the first port is requested, so a template without auto_port() -
|
|
227
|
+
# and every caller that already knows the used ports - never walks the
|
|
228
|
+
# worktrees. Once filled, it is reused for the rest of the file.
|
|
229
|
+
file_ports: PortSet | None = set(used_ports) if used_ports is not None else None
|
|
156
230
|
|
|
157
231
|
for line in content.splitlines():
|
|
158
232
|
# Process {{ auto_port() | default }} placeholders
|
|
@@ -162,10 +236,13 @@ def parse_env_template(
|
|
|
162
236
|
if default_value is not None:
|
|
163
237
|
default_value = default_value.strip()
|
|
164
238
|
|
|
165
|
-
# Generate available port
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
239
|
+
# Generate available port. Scan the worktrees on the first
|
|
240
|
+
# placeholder only; from then on file_ports carries both those
|
|
241
|
+
# ports and the ones assigned earlier in this file.
|
|
242
|
+
nonlocal file_ports
|
|
243
|
+
if file_ports is None:
|
|
244
|
+
file_ports = get_used_ports()
|
|
245
|
+
port = find_available_port(file_ports)
|
|
169
246
|
file_ports.add(port)
|
|
170
247
|
return str(port)
|
|
171
248
|
|
|
@@ -4,6 +4,7 @@ import subprocess
|
|
|
4
4
|
|
|
5
5
|
from typer.testing import CliRunner
|
|
6
6
|
|
|
7
|
+
import sprout.utils
|
|
7
8
|
from sprout.cli import app
|
|
8
9
|
from sprout.utils import get_used_ports
|
|
9
10
|
|
|
@@ -146,6 +147,28 @@ PORT2={{ auto_port() }}
|
|
|
146
147
|
# Ensure no overlap
|
|
147
148
|
assert len(ports1.intersection(ports2)) == 0, "Ports should not overlap between worktrees"
|
|
148
149
|
|
|
150
|
+
def test_create_scans_the_workspace_once(self, git_repo, monkeypatch, mocker): # noqa: F811
|
|
151
|
+
"""Test one create walks the worktrees once, not once per port."""
|
|
152
|
+
git_repo, _default_branch = git_repo
|
|
153
|
+
monkeypatch.chdir(git_repo)
|
|
154
|
+
monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example
|
|
155
|
+
|
|
156
|
+
service = git_repo / "service"
|
|
157
|
+
service.mkdir()
|
|
158
|
+
(service / ".env.example").write_text(
|
|
159
|
+
"P1={{ auto_port() }}\nP2={{ auto_port() }}\nP3={{ auto_port() }}\n"
|
|
160
|
+
)
|
|
161
|
+
subprocess.run(["git", "add", "."], cwd=git_repo, check=True)
|
|
162
|
+
subprocess.run(["git", "commit", "-m", "Add service"], cwd=git_repo, check=True)
|
|
163
|
+
|
|
164
|
+
# Every scan funnels through iter_env_files, whichever module asked for it
|
|
165
|
+
spy = mocker.spy(sprout.utils, "iter_env_files")
|
|
166
|
+
|
|
167
|
+
result = runner.invoke(app, ["create", "scan-once"])
|
|
168
|
+
|
|
169
|
+
assert result.exit_code == 0
|
|
170
|
+
assert spy.call_count == 1
|
|
171
|
+
|
|
149
172
|
def test_nested_directory_structure(self, git_repo, monkeypatch): # noqa: F811
|
|
150
173
|
"""Test handling of nested directory structures."""
|
|
151
174
|
git_repo, default_branch = git_repo
|
|
@@ -10,12 +10,14 @@ import pytest
|
|
|
10
10
|
|
|
11
11
|
from sprout.exceptions import SproutError
|
|
12
12
|
from sprout.utils import (
|
|
13
|
+
SCAN_EXCLUDED_DIRS,
|
|
13
14
|
branch_exists,
|
|
14
15
|
find_available_port,
|
|
15
16
|
get_git_root,
|
|
16
17
|
get_used_ports,
|
|
17
18
|
is_git_repository,
|
|
18
19
|
is_port_available,
|
|
20
|
+
iter_env_files,
|
|
19
21
|
parse_env_template,
|
|
20
22
|
run_command,
|
|
21
23
|
worktree_exists,
|
|
@@ -100,6 +102,36 @@ class TestPortUtils:
|
|
|
100
102
|
ports = get_used_ports()
|
|
101
103
|
assert ports == set()
|
|
102
104
|
|
|
105
|
+
def test_get_used_ports_skips_dependency_directories(self, tmp_path, mocker):
|
|
106
|
+
"""Test get_used_ports ignores .env files under dependency directories."""
|
|
107
|
+
mocker.patch("sprout.utils.get_sprout_dir", return_value=tmp_path)
|
|
108
|
+
|
|
109
|
+
(tmp_path / "branch1").mkdir()
|
|
110
|
+
(tmp_path / "branch1" / ".env").write_text("WEB_PORT=8080")
|
|
111
|
+
|
|
112
|
+
# Dependency and cache trees can ship their own .env files, and walking
|
|
113
|
+
# them is what made port allocation slow on large worktrees.
|
|
114
|
+
vendored = tmp_path / "branch1" / "node_modules" / "some-package"
|
|
115
|
+
vendored.mkdir(parents=True)
|
|
116
|
+
(vendored / ".env").write_text("VENDOR_PORT=9999")
|
|
117
|
+
|
|
118
|
+
virtualenv = tmp_path / "branch1" / ".venv" / "lib"
|
|
119
|
+
virtualenv.mkdir(parents=True)
|
|
120
|
+
(virtualenv / ".env").write_text("VENV_PORT=9998")
|
|
121
|
+
|
|
122
|
+
assert get_used_ports() == {8080}
|
|
123
|
+
|
|
124
|
+
def test_get_used_ports_ignores_unreadable_files(self, tmp_path, mocker):
|
|
125
|
+
"""Test a .env that cannot be read is skipped, not fatal."""
|
|
126
|
+
mocker.patch("sprout.utils.get_sprout_dir", return_value=tmp_path)
|
|
127
|
+
|
|
128
|
+
(tmp_path / "branch1").mkdir()
|
|
129
|
+
(tmp_path / "branch1" / ".env").write_text("WEB_PORT=8080")
|
|
130
|
+
# Dangling symlink: reported by the walk, unreadable when opened
|
|
131
|
+
(tmp_path / "branch1" / "broken.env").symlink_to(tmp_path / "gone" / ".env")
|
|
132
|
+
|
|
133
|
+
assert get_used_ports() == {8080}
|
|
134
|
+
|
|
103
135
|
def test_is_port_available_true(self):
|
|
104
136
|
"""Test is_port_available returns True for free port."""
|
|
105
137
|
# Find a likely free port
|
|
@@ -137,6 +169,93 @@ class TestPortUtils:
|
|
|
137
169
|
with pytest.raises(SproutError, match="Could not find an available port"):
|
|
138
170
|
find_available_port()
|
|
139
171
|
|
|
172
|
+
def test_find_available_port_accepts_precomputed_ports(self, mocker):
|
|
173
|
+
"""Test find_available_port skips the workspace scan when given ports."""
|
|
174
|
+
scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
|
|
175
|
+
mocker.patch("sprout.utils.is_port_available", return_value=True)
|
|
176
|
+
mocker.patch("random.randint", side_effect=[8080, 8081])
|
|
177
|
+
|
|
178
|
+
port = find_available_port({8080})
|
|
179
|
+
|
|
180
|
+
assert port == 8081
|
|
181
|
+
scan.assert_not_called()
|
|
182
|
+
|
|
183
|
+
def test_find_available_port_empty_set_is_not_a_rescan(self, mocker):
|
|
184
|
+
"""Test an empty set means 'nothing taken', not 'go and scan'."""
|
|
185
|
+
scan = mocker.patch("sprout.utils.get_used_ports", return_value={8080})
|
|
186
|
+
mocker.patch("sprout.utils.is_port_available", return_value=True)
|
|
187
|
+
mocker.patch("random.randint", side_effect=[8080])
|
|
188
|
+
|
|
189
|
+
assert find_available_port(set()) == 8080
|
|
190
|
+
scan.assert_not_called()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestIterEnvFiles:
|
|
194
|
+
"""Test the .env discovery walk."""
|
|
195
|
+
|
|
196
|
+
# Spelled out rather than derived from SCAN_EXCLUDED_DIRS: deriving it would
|
|
197
|
+
# make the test shrink along with the constant instead of failing.
|
|
198
|
+
EXCLUDED_DIRS = [
|
|
199
|
+
".direnv",
|
|
200
|
+
".git",
|
|
201
|
+
".mypy_cache",
|
|
202
|
+
".next",
|
|
203
|
+
".pytest_cache",
|
|
204
|
+
".ruff_cache",
|
|
205
|
+
".terraform",
|
|
206
|
+
".tox",
|
|
207
|
+
".venv",
|
|
208
|
+
"__pycache__",
|
|
209
|
+
"node_modules",
|
|
210
|
+
"venv",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
def test_excluded_dirs_constant_is_in_sync(self):
|
|
214
|
+
"""Test the spelled-out list still matches the implementation."""
|
|
215
|
+
assert set(self.EXCLUDED_DIRS) == SCAN_EXCLUDED_DIRS
|
|
216
|
+
|
|
217
|
+
@pytest.mark.parametrize("excluded", EXCLUDED_DIRS)
|
|
218
|
+
def test_iter_env_files_skips_excluded_directory(self, tmp_path, excluded):
|
|
219
|
+
"""Test each excluded directory is left unwalked."""
|
|
220
|
+
(tmp_path / "worktree").mkdir()
|
|
221
|
+
(tmp_path / "worktree" / ".env").write_text("PORT=8080")
|
|
222
|
+
|
|
223
|
+
buried = tmp_path / "worktree" / excluded / "nested"
|
|
224
|
+
buried.mkdir(parents=True)
|
|
225
|
+
(buried / ".env").write_text("PORT=9999")
|
|
226
|
+
|
|
227
|
+
assert list(iter_env_files(tmp_path)) == [tmp_path / "worktree" / ".env"]
|
|
228
|
+
|
|
229
|
+
@pytest.mark.parametrize("excluded", EXCLUDED_DIRS)
|
|
230
|
+
def test_iter_env_files_walks_worktree_named_after_excluded_dir(self, tmp_path, excluded):
|
|
231
|
+
"""Test a worktree is walked even when its name is on the exclude list."""
|
|
232
|
+
# A branch named e.g. "venv" or "fix/venv" puts a real worktree behind an
|
|
233
|
+
# excluded name; its ports must still be collected.
|
|
234
|
+
worktree = tmp_path / excluded
|
|
235
|
+
worktree.mkdir()
|
|
236
|
+
(worktree / ".git").write_text("gitdir: /somewhere/.git/worktrees/venv\n")
|
|
237
|
+
(worktree / ".env").write_text("PORT=8080")
|
|
238
|
+
|
|
239
|
+
assert list(iter_env_files(tmp_path)) == [worktree / ".env"]
|
|
240
|
+
|
|
241
|
+
def test_iter_env_files_matches_suffixed_names(self, tmp_path):
|
|
242
|
+
"""Test names ending in .env are matched, as rglob('*.env') did."""
|
|
243
|
+
(tmp_path / ".env").write_text("A=1")
|
|
244
|
+
(tmp_path / "local.env").write_text("B=2")
|
|
245
|
+
(tmp_path / "env").write_text("C=3")
|
|
246
|
+
(tmp_path / "notes.txt").write_text("D=4")
|
|
247
|
+
|
|
248
|
+
assert {path.name for path in iter_env_files(tmp_path)} == {".env", "local.env"}
|
|
249
|
+
|
|
250
|
+
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="requires mkfifo")
|
|
251
|
+
def test_iter_env_files_skips_non_regular_files(self, tmp_path):
|
|
252
|
+
"""Test a fifo named .env is skipped instead of blocking a read."""
|
|
253
|
+
os.mkfifo(tmp_path / ".env")
|
|
254
|
+
(tmp_path / "real").mkdir()
|
|
255
|
+
(tmp_path / "real" / ".env").write_text("PORT=8080")
|
|
256
|
+
|
|
257
|
+
assert list(iter_env_files(tmp_path)) == [tmp_path / "real" / ".env"]
|
|
258
|
+
|
|
140
259
|
|
|
141
260
|
class TestEnvTemplateParser:
|
|
142
261
|
"""Test .env template parsing."""
|
|
@@ -151,6 +270,70 @@ class TestEnvTemplateParser:
|
|
|
151
270
|
result = parse_env_template(template)
|
|
152
271
|
assert result == "WEB_PORT=8080\nAPI_PORT=3000"
|
|
153
272
|
|
|
273
|
+
def test_parse_env_template_scans_workspace_once(self, tmp_path, mocker):
|
|
274
|
+
"""Test the workspace is scanned once, not once per auto_port()."""
|
|
275
|
+
scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
|
|
276
|
+
mocker.patch("sprout.utils.is_port_available", return_value=True)
|
|
277
|
+
mocker.patch("random.randint", side_effect=[8080, 8081, 8082])
|
|
278
|
+
|
|
279
|
+
template = tmp_path / ".env.example"
|
|
280
|
+
template.write_text(
|
|
281
|
+
"A_PORT={{ auto_port() }}\nB_PORT={{ auto_port() }}\nC_PORT={{ auto_port() }}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
result = parse_env_template(template)
|
|
285
|
+
|
|
286
|
+
assert result == "A_PORT=8080\nB_PORT=8081\nC_PORT=8082"
|
|
287
|
+
assert scan.call_count == 1
|
|
288
|
+
|
|
289
|
+
def test_parse_env_template_reuses_given_used_ports(self, tmp_path, mocker):
|
|
290
|
+
"""Test a caller-supplied port set replaces the workspace scan."""
|
|
291
|
+
scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
|
|
292
|
+
mocker.patch("sprout.utils.is_port_available", return_value=True)
|
|
293
|
+
mocker.patch("random.randint", side_effect=[8080, 8081])
|
|
294
|
+
|
|
295
|
+
template = tmp_path / ".env.example"
|
|
296
|
+
template.write_text("WEB_PORT={{ auto_port() }}")
|
|
297
|
+
|
|
298
|
+
result = parse_env_template(template, used_ports={8080})
|
|
299
|
+
|
|
300
|
+
assert result == "WEB_PORT=8081"
|
|
301
|
+
scan.assert_not_called()
|
|
302
|
+
|
|
303
|
+
def test_parse_env_template_empty_used_ports_is_not_a_rescan(self, tmp_path, mocker):
|
|
304
|
+
"""Test an empty port set is taken at face value, not as 'unscanned'."""
|
|
305
|
+
scan = mocker.patch("sprout.utils.get_used_ports", return_value={8080})
|
|
306
|
+
mocker.patch("sprout.utils.is_port_available", return_value=True)
|
|
307
|
+
mocker.patch("random.randint", side_effect=[8080])
|
|
308
|
+
|
|
309
|
+
template = tmp_path / ".env.example"
|
|
310
|
+
template.write_text("WEB_PORT={{ auto_port() }}")
|
|
311
|
+
|
|
312
|
+
assert parse_env_template(template, used_ports=set()) == "WEB_PORT=8080"
|
|
313
|
+
scan.assert_not_called()
|
|
314
|
+
|
|
315
|
+
def test_parse_env_template_without_auto_port_does_not_scan(self, tmp_path, mocker):
|
|
316
|
+
"""Test a template with no auto_port() never touches the workspace."""
|
|
317
|
+
scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
|
|
318
|
+
|
|
319
|
+
template = tmp_path / ".env.example"
|
|
320
|
+
template.write_text("NAME=static\nURL=${HOST}/path")
|
|
321
|
+
|
|
322
|
+
assert parse_env_template(template) == "NAME=static\nURL=${HOST}/path"
|
|
323
|
+
scan.assert_not_called()
|
|
324
|
+
|
|
325
|
+
def test_parse_env_template_never_repeats_a_port_within_a_file(self, tmp_path, mocker):
|
|
326
|
+
"""Test a port assigned earlier in the file is excluded from later draws."""
|
|
327
|
+
mocker.patch("sprout.utils.get_used_ports", return_value=set())
|
|
328
|
+
mocker.patch("sprout.utils.is_port_available", return_value=True)
|
|
329
|
+
# The same port is drawn twice; the second placeholder must reject it
|
|
330
|
+
mocker.patch("random.randint", side_effect=[8080, 8080, 8081])
|
|
331
|
+
|
|
332
|
+
template = tmp_path / ".env.example"
|
|
333
|
+
template.write_text("A={{ auto_port() }}\nB={{ auto_port() }}")
|
|
334
|
+
|
|
335
|
+
assert parse_env_template(template) == "A=8080\nB=8081"
|
|
336
|
+
|
|
154
337
|
def test_parse_env_template_variable_from_env(self, tmp_path, mocker):
|
|
155
338
|
"""Test parsing {{ VARIABLE }} from environment."""
|
|
156
339
|
mocker.patch.dict(os.environ, {"API_KEY": "secret123"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|