Skip to content

Commit 333555f

Browse files
committed
Improves search up
1 parent 7747a52 commit 333555f

2 files changed

Lines changed: 247 additions & 26 deletions

File tree

easy_django_cli/cli.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
"__pycache__", # Python cache
1111
"node_modules", # JavaScript dependencies
1212
"static", "build", "dist", # Build artifacts
13+
"logs", "log", "tmp", # Log directories
14+
"media" # Media upload directories
1315
])
1416

17+
TOP_LEVEL_SEARCH_MAX_DEPTH = 3 # Max depth to search if no top-level dir is found
18+
1519

1620
def run_manage_py_command(manage_py_path: Path, args: list[str]) -> int:
1721
"""
@@ -96,9 +100,9 @@ def execute_django_command(manage_py_path: Optional[Path] = None) -> int:
96100

97101

98102
def _find_files_scan_dir(
99-
directory: Path | str,
100-
filename: str,
101-
skip_dirs: frozenset[str]
103+
directory: Path | str,
104+
filename: str,
105+
skip_dirs: frozenset[str]
102106
) -> Optional[str]:
103107
"""
104108
Recursively scan a directory tree for a specific filename.
@@ -137,14 +141,15 @@ def _find_files_scan_dir(
137141
if result := _find_files_scan_dir(dir_path, filename, skip_dirs):
138142
return result
139143

140-
except PermissionError:
141-
# Skip directories without read permission (common in system directories)
144+
except (PermissionError, OSError):
145+
# Skip directories without read permission or with other OS-level errors
146+
# (common in system directories, special filesystems, etc.)
142147
pass
143148

144149
return None
145150

146151

147-
def find_manage_py(start_path: Optional[Path] = None) -> Optional[Path]:
152+
def find_manage_py() -> Optional[Path]:
148153
"""
149154
Locate Django's manage.py file by recursively searching upward through parent directories.
150155
@@ -153,37 +158,63 @@ def find_manage_py(start_path: Optional[Path] = None) -> Optional[Path]:
153158
subdirectories. This approach finds manage.py even if it's in a sibling directory
154159
or nested subdirectory relative to the starting point.
155160
156-
Args:
157-
start_path: Directory to start searching from (defaults to current directory)
161+
The search stops at natural boundaries (git repository root, home directory, or
162+
filesystem root) to avoid scanning unrelated projects.
158163
159164
Returns:
160165
Path object pointing to manage.py if found, None otherwise
161166
"""
162-
if start_path is None:
163-
start_path = Path.cwd()
164-
165-
current = start_path.resolve()
167+
current = Path.cwd().resolve()
166168
search_current = current
169+
top_level_dir = _get_top_level_directory()
170+
level = 0
167171

168172
# Search upward through directory hierarchy
169173
while True:
170174
# Recursively search current level and all subdirectories
171175
if manage_py_str := _find_files_scan_dir(
172-
search_current, "manage.py", SKIP_DIRS
176+
search_current, "manage.py", SKIP_DIRS
173177
):
174178
return Path(manage_py_str)
175179

180+
# Stop at natural boundaries to avoid searching unrelated projects
181+
# Stop if we find a .git directory (project root marker)
182+
if (search_current / ".git").exists():
183+
# We've searched this git repo and didn't find manage.py
184+
break
185+
186+
# If we don't have a top level directory, we limit search depth to avoid system-wide search
187+
if not top_level_dir and level >= TOP_LEVEL_SEARCH_MAX_DEPTH:
188+
return None
189+
190+
# Stop at top level directory searching system-wide
191+
if search_current == top_level_dir:
192+
break
193+
176194
# Move to parent directory
177195
parent = search_current.parent
178196
if parent == search_current:
179197
# Reached filesystem root without finding manage.py
180198
break
181199

182200
search_current = parent
201+
level += 1
183202

184203
return None
185204

186205

206+
def _get_top_level_directory() -> Optional[Path]:
207+
"""
208+
Determine the top-level directory to limit manage.py search.
209+
"""
210+
try:
211+
from django.conf import settings
212+
if base_dir := getattr(settings, "BASE_DIR", None):
213+
return Path(base_dir).parent.resolve()
214+
except Exception:
215+
return None
216+
217+
187218
def main() -> int:
188219
"""
189220
Main entry point for the easy-django CLI tool.

tests/test_cli.py

Lines changed: 203 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
from pathlib import Path
33
from unittest.mock import patch
44

5-
from easy_django_cli.cli import execute_django_command, find_manage_py, main
5+
from easy_django_cli.cli import (
6+
_get_top_level_directory,
7+
execute_django_command,
8+
find_manage_py,
9+
main,
10+
)
611

712

813
class TestFindManagePy:
@@ -12,55 +17,240 @@ def test_find_manage_py_in_current_dir(self, temp_dir: Path) -> None:
1217
WHEN: Searching for manage.py in that directory
1318
THEN: The manage.py file should be found
1419
"""
20+
import os
21+
1522
manage_py = temp_dir / "manage.py"
1623
manage_py.write_text("# manage.py")
1724

18-
result = find_manage_py(temp_dir)
25+
original_cwd = os.getcwd()
26+
try:
27+
os.chdir(temp_dir)
28+
result = find_manage_py()
1929

20-
assert result is not None
21-
assert result.resolve() == manage_py.resolve()
30+
assert result is not None
31+
assert result.resolve() == manage_py.resolve()
32+
finally:
33+
os.chdir(original_cwd)
2234

2335
def test_find_manage_py_in_parent_dir(self, temp_dir: Path) -> None:
2436
"""
2537
GIVEN: A parent directory with manage.py and a subdirectory
2638
WHEN: Searching for manage.py from the subdirectory
2739
THEN: The manage.py in the parent directory should be found
2840
"""
41+
import os
42+
2943
manage_py = temp_dir / "manage.py"
3044
manage_py.write_text("# manage.py")
3145
subdir = temp_dir / "subdir"
3246
subdir.mkdir()
3347

34-
result = find_manage_py(subdir)
48+
original_cwd = os.getcwd()
49+
try:
50+
os.chdir(subdir)
51+
result = find_manage_py()
3552

36-
assert result is not None
37-
assert result.resolve() == manage_py.resolve()
53+
assert result is not None
54+
assert result.resolve() == manage_py.resolve()
55+
finally:
56+
os.chdir(original_cwd)
3857

3958
def test_find_manage_py_not_found(self, temp_dir: Path) -> None:
4059
"""
4160
GIVEN: A directory without manage.py file
4261
WHEN: Searching for manage.py in that directory
4362
THEN: No manage.py should be found
4463
"""
45-
result = find_manage_py(temp_dir)
64+
import os
4665

47-
assert result is None
66+
original_cwd = os.getcwd()
67+
try:
68+
os.chdir(temp_dir)
69+
result = find_manage_py()
70+
71+
assert result is None
72+
finally:
73+
os.chdir(original_cwd)
4874

4975
def test_find_manage_py_max_depth(self, temp_dir: Path) -> None:
5076
"""
5177
GIVEN: A manage.py at the root and a deeply nested directory
5278
WHEN: Searching for manage.py from the deeply nested directory
5379
THEN: The manage.py at the root should be found
5480
"""
81+
import os
82+
83+
manage_py = temp_dir / "manage.py"
84+
manage_py.write_text("# manage.py")
85+
deep_dir = temp_dir / "a" / "b" / "c"
86+
deep_dir.mkdir(parents=True)
87+
88+
original_cwd = os.getcwd()
89+
try:
90+
os.chdir(deep_dir)
91+
result = find_manage_py()
92+
93+
assert result is not None
94+
assert result.resolve() == manage_py.resolve()
95+
finally:
96+
os.chdir(original_cwd)
97+
98+
def test_find_manage_py_max_depth_fails(self, temp_dir: Path) -> None:
99+
"""
100+
GIVEN: A manage.py beyond the max search depth
101+
WHEN: Searching for manage.py from a deeply nested directory
102+
THEN: No manage.py should be found
103+
"""
104+
import os
105+
55106
manage_py = temp_dir / "manage.py"
56107
manage_py.write_text("# manage.py")
57-
deep_dir = temp_dir / "a" / "b" / "c" / "d" / "e" / "f"
108+
deep_dir = temp_dir / "a" / "b" / "c" / "d"
58109
deep_dir.mkdir(parents=True)
59110

60-
result = find_manage_py(deep_dir)
111+
original_cwd = os.getcwd()
112+
try:
113+
os.chdir(deep_dir)
114+
result = find_manage_py()
115+
116+
assert result is None
117+
finally:
118+
os.chdir(original_cwd)
119+
120+
def test_find_manage_py_stops_at_git_boundary(self, temp_dir: Path) -> None:
121+
"""
122+
GIVEN: A git repository without manage.py and a parent directory with manage.py
123+
WHEN: Searching for manage.py from inside the git repo
124+
THEN: The search should stop at the git boundary and not find the parent's manage.py
125+
"""
126+
import os
127+
128+
# Create a manage.py in the parent directory
129+
parent_manage_py = temp_dir / "manage.py"
130+
parent_manage_py.write_text("# parent manage.py")
131+
132+
# Create a subdirectory with .git (simulating a git repo)
133+
git_repo = temp_dir / "git_repo"
134+
git_repo.mkdir()
135+
git_dir = git_repo / ".git"
136+
git_dir.mkdir()
137+
138+
# Create a subdirectory inside the git repo
139+
subdir = git_repo / "subdir"
140+
subdir.mkdir()
141+
142+
# Change to the subdir and search from there
143+
original_cwd = os.getcwd()
144+
try:
145+
os.chdir(subdir)
146+
result = find_manage_py()
147+
148+
# Should not find the parent's manage.py due to git boundary
149+
assert result is None
150+
finally:
151+
os.chdir(original_cwd)
152+
153+
def test_find_manage_py_finds_within_git_repo(self, temp_dir: Path) -> None:
154+
"""
155+
GIVEN: A git repository with manage.py inside it
156+
WHEN: Searching for manage.py from a subdirectory in the repo
157+
THEN: The manage.py inside the git repo should be found
158+
"""
159+
import os
160+
161+
# Create a git repo directory with .git
162+
git_repo = temp_dir / "git_repo"
163+
git_repo.mkdir()
164+
git_dir = git_repo / ".git"
165+
git_dir.mkdir()
166+
167+
# Create manage.py inside the git repo
168+
manage_py = git_repo / "manage.py"
169+
manage_py.write_text("# manage.py")
170+
171+
# Create a subdirectory inside the git repo
172+
subdir = git_repo / "subdir"
173+
subdir.mkdir()
174+
175+
# Change to the subdir and search from there
176+
original_cwd = os.getcwd()
177+
try:
178+
os.chdir(subdir)
179+
result = find_manage_py()
180+
181+
# Should find manage.py inside the git repo
182+
assert result is not None
183+
assert result.resolve() == manage_py.resolve()
184+
finally:
185+
os.chdir(original_cwd)
186+
187+
188+
class TestGetTopLevelDirectory:
189+
def test_get_top_level_directory_no_django(self) -> None:
190+
"""
191+
GIVEN: Django is not installed or not configured
192+
WHEN: Calling _get_top_level_directory
193+
THEN: Should return None without raising exceptions
194+
"""
195+
result = _get_top_level_directory()
196+
197+
# Should handle missing Django gracefully
198+
assert result is None
199+
200+
def test_get_top_level_directory_with_django_configured(
201+
self, temp_dir: Path
202+
) -> None:
203+
"""
204+
GIVEN: Django is configured with BASE_DIR in settings
205+
WHEN: Calling _get_top_level_directory
206+
THEN: Should return the parent of BASE_DIR
207+
"""
208+
# Create a mock settings module with BASE_DIR
209+
base_dir = temp_dir / "project"
210+
base_dir.mkdir()
211+
212+
with patch("django.conf.settings") as mock_settings:
213+
mock_settings.BASE_DIR = str(base_dir)
214+
result = _get_top_level_directory()
215+
216+
assert result is not None
217+
assert result.resolve() == temp_dir.resolve()
218+
219+
def test_get_top_level_directory_no_base_dir(self) -> None:
220+
"""
221+
GIVEN: Django is configured but settings has no BASE_DIR
222+
WHEN: Calling _get_top_level_directory
223+
THEN: Should return None
224+
"""
225+
with patch("django.conf.settings") as mock_settings:
226+
# Mock settings without BASE_DIR attribute
227+
del mock_settings.BASE_DIR
228+
mock_settings.BASE_DIR = None
229+
230+
result = _get_top_level_directory()
231+
232+
assert result is None
233+
234+
def test_get_top_level_directory_handles_improperly_configured(self) -> None:
235+
"""
236+
GIVEN: Django raises ImproperlyConfigured when accessing settings
237+
WHEN: Calling _get_top_level_directory
238+
THEN: Should return None without raising exceptions
239+
"""
240+
from django.core.exceptions import ImproperlyConfigured
241+
242+
with patch("django.conf.settings") as mock_settings:
243+
# Simulate ImproperlyConfigured error
244+
type(mock_settings).BASE_DIR = property(
245+
lambda self: (_ for _ in ()).throw(
246+
ImproperlyConfigured("Django is not configured")
247+
)
248+
)
249+
250+
result = _get_top_level_directory()
61251

62-
assert result is not None
63-
assert result.resolve() == manage_py.resolve()
252+
# Should catch the exception and return None
253+
assert result is None
64254

65255

66256
class TestExecuteDjangoCommand:

0 commit comments

Comments
 (0)