Skip to content

Commit 8368ae4

Browse files
committed
feat(deps): Implement dependency lister tool (deps command)
Adds a new "Dependency Lister" tool, introducing the `contextcraft deps` command to the CLI. Core features: - Parses dependencies from Python projects (pyproject.toml for Poetry/PEP621, requirements.txt) and Node.js projects (package.json). - Supports various dependency groups (main, dev, peer, optional) and Python extras. - Integrates with the configuration manager for a default output filename (`default_output_filename_deps`). - Outputs dependencies in a structured Markdown format suitable for console display (via Rich) or saving to a file. - Includes comprehensive unit tests (`test_dependency_lister.py`) with high coverage for the new module. - Code adheres to project quality standards (Ruff, Mypy, Bandit).
2 parents 9616b54 + cc51c25 commit 8368ae4

7 files changed

Lines changed: 396 additions & 405 deletions

File tree

coverage.xml

Lines changed: 283 additions & 269 deletions
Large diffs are not rendered by default.

src/contextcraft/main.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,7 @@ def flatten_command(
274274
output_file_path=actual_output_path,
275275
include_patterns=actual_include_patterns,
276276
exclude_patterns=actual_exclude_patterns,
277-
# config_global_include_patterns=config.get("global_include_patterns", []), # For later
278-
# config_global_exclude_patterns=config.get("global_exclude_patterns", []) # For later
277+
config_global_excludes=cfg_global_excludes,
279278
)
280279
except typer.Exit:
281280
raise
@@ -335,8 +334,7 @@ def deps_command(
335334
console.print(f"[dim]Using default output file from config: {actual_output_path.resolve()}[/dim]")
336335
else:
337336
warnings.warn(
338-
f"Config Warning: 'default_output_filename_deps' should be a string, "
339-
f"got {type(cfg_output_filename)}. Outputting to console.",
337+
f"Config Warning: 'default_output_filename_deps' should be a string, " f"got {type(cfg_output_filename)}. Outputting to console.",
340338
UserWarning,
341339
stacklevel=2,
342340
)

src/contextcraft/tools/dependency_lister.py

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,7 @@ class DependencyInfo:
4848
group: Dependency group (e.g., 'dev', 'test', 'main')
4949
"""
5050

51-
def __init__(
52-
self,
53-
name: str,
54-
version: Optional[str] = None,
55-
extras: Optional[List[str]] = None,
56-
group: str = "main"
57-
):
51+
def __init__(self, name: str, version: Optional[str] = None, extras: Optional[List[str]] = None, group: str = "main"):
5852
self.name = name
5953
self.version = version
6054
self.extras = extras or []
@@ -188,17 +182,17 @@ def _parse_requirement_string(self, req_string: str) -> Tuple[str, Optional[str]
188182

189183
# Extract extras
190184
extras = []
191-
extras_match = re.search(r'\[([^\]]+)\]', req_string)
185+
extras_match = re.search(r"\[([^\]]+)\]", req_string)
192186
if extras_match:
193-
extras = [e.strip() for e in extras_match.group(1).split(',')]
194-
req_string = req_string.replace(extras_match.group(0), '')
187+
extras = [e.strip() for e in extras_match.group(1).split(",")]
188+
req_string = req_string.replace(extras_match.group(0), "")
195189

196190
# Extract version constraint
197-
version_match = re.search(r'([<>=!~]+.+)', req_string)
191+
version_match = re.search(r"([<>=!~]+.+)", req_string)
198192
version = version_match.group(1) if version_match else None
199193

200194
# Extract package name
201-
name = re.sub(r'[<>=!~].*', '', req_string).strip()
195+
name = re.sub(r"[<>=!~].*", "", req_string).strip()
202196

203197
return name, version, extras
204198

@@ -250,16 +244,16 @@ def parse(self) -> List[DependencyInfo]:
250244
line = line.strip()
251245

252246
# Skip empty lines and comments
253-
if not line or line.startswith('#'):
247+
if not line or line.startswith("#"):
254248
continue
255249

256250
# Skip -r, -f, --find-links, etc. (requirement file options)
257-
if line.startswith('-'):
251+
if line.startswith("-"):
258252
continue
259253

260254
# Remove inline comments
261-
if '#' in line:
262-
line = line.split('#')[0].strip()
255+
if "#" in line:
256+
line = line.split("#")[0].strip()
263257

264258
if line:
265259
try:
@@ -281,32 +275,32 @@ def _determine_group_from_filename(self) -> str:
281275
"""Determine dependency group from filename."""
282276
filename = self.file_path.name.lower()
283277

284-
if 'dev' in filename:
285-
return 'dev'
286-
elif 'test' in filename:
287-
return 'test'
288-
elif 'prod' in filename or 'production' in filename:
289-
return 'production'
278+
if "dev" in filename:
279+
return "dev"
280+
elif "test" in filename:
281+
return "test"
282+
elif "prod" in filename or "production" in filename:
283+
return "production"
290284
else:
291-
return 'main'
285+
return "main"
292286

293287
def _parse_requirement_string(self, req_string: str) -> Tuple[str, Optional[str], List[str]]:
294288
"""Parse a requirement string like 'package[extra1,extra2]>=1.0'."""
295289
req_string = req_string.strip()
296290

297291
# Extract extras
298292
extras = []
299-
extras_match = re.search(r'\[([^\]]+)\]', req_string)
293+
extras_match = re.search(r"\[([^\]]+)\]", req_string)
300294
if extras_match:
301-
extras = [e.strip() for e in extras_match.group(1).split(',')]
302-
req_string = req_string.replace(extras_match.group(0), '')
295+
extras = [e.strip() for e in extras_match.group(1).split(",")]
296+
req_string = req_string.replace(extras_match.group(0), "")
303297

304298
# Extract version constraint
305-
version_match = re.search(r'([<>=!~]+.+)', req_string)
299+
version_match = re.search(r"([<>=!~]+.+)", req_string)
306300
version = version_match.group(1) if version_match else None
307301

308302
# Extract package name
309-
name = re.sub(r'[<>=!~].*', '', req_string).strip()
303+
name = re.sub(r"[<>=!~].*", "", req_string).strip()
310304

311305
return name, version, extras
312306

@@ -381,11 +375,11 @@ def discover_dependency_files(project_path: Path) -> List[Path]:
381375
"requirements-test.txt",
382376
"requirements-prod.txt",
383377
"requirements/*.txt",
384-
"requirements/*.in"
378+
"requirements/*.in",
385379
]
386380

387381
for pattern in requirements_patterns:
388-
if '*' in pattern:
382+
if "*" in pattern:
389383
# Handle glob patterns
390384
for path in project_path.glob(pattern):
391385
if path.is_file():
@@ -417,17 +411,15 @@ def create_parser(file_path: Path) -> Optional[PackageManagerParser]:
417411

418412
if filename == "pyproject.toml":
419413
return PyProjectTomlParser(file_path)
420-
elif filename.endswith(('.txt', '.in')) and 'requirements' in filename:
414+
elif filename.endswith((".txt", ".in")) and "requirements" in filename:
421415
return RequirementsTxtParser(file_path)
422416
elif filename == "package.json":
423417
return PackageJsonParser(file_path)
424418

425419
return None
426420

427421

428-
def format_dependencies_as_markdown(
429-
dependency_data: Dict[str, Dict[str, List[DependencyInfo]]]
430-
) -> str:
422+
def format_dependencies_as_markdown(dependency_data: Dict[str, Dict[str, List[DependencyInfo]]]) -> str:
431423
"""
432424
Format dependency data as structured Markdown.
433425
@@ -461,7 +453,7 @@ def format_dependencies_as_markdown(
461453

462454
for group, group_deps in grouped_deps.items():
463455
if len(grouped_deps) > 1: # Only show group header if there are multiple groups
464-
group_title = group.replace('_', ' ').title()
456+
group_title = group.replace("_", " ").title()
465457
markdown_lines.append(f"#### {group_title} Dependencies\n")
466458

467459
# Sort dependencies alphabetically
@@ -480,10 +472,7 @@ def format_dependencies_as_markdown(
480472
return "\n".join(markdown_lines)
481473

482474

483-
def list_dependencies_logic(
484-
project_path: Path,
485-
actual_output_path: Optional[Path]
486-
) -> None:
475+
def list_dependencies_logic(project_path: Path, actual_output_path: Optional[Path]) -> None:
487476
"""
488477
Main logic function for listing project dependencies.
489478

src/contextcraft/utils/ignore_handler.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ def is_path_ignored(
152152
if relative_path_for_spec:
153153
rel_path_str = relative_path_for_spec.as_posix()
154154
current_path_obj_for_match = Path(rel_path_str)
155+
156+
# For directory patterns ending with "/", check if this is a directory
155157
if pattern.endswith("/") and path_to_check_abs.is_dir():
156158
path_to_match_as_dir = rel_path_str
157159
if not path_to_match_as_dir.endswith("/"):
@@ -160,6 +162,17 @@ def is_path_ignored(
160162
return True
161163
if current_path_obj_for_match.name + "/" == pattern:
162164
return True
165+
166+
# For directory patterns ending with "/", also check if any parent directory matches
167+
if pattern.endswith("/"):
168+
# Check if any parent directory of the file matches the pattern
169+
for parent in current_path_obj_for_match.parents:
170+
parent_str = parent.as_posix()
171+
if parent_str + "/" == pattern:
172+
return True
173+
if parent.name + "/" == pattern:
174+
return True
175+
163176
if current_path_obj_for_match.match(pattern):
164177
return True
165178

@@ -175,6 +188,8 @@ def is_path_ignored(
175188
if relative_path_for_spec:
176189
rel_path_str_cli = relative_path_for_spec.as_posix()
177190
current_path_for_cli_match = Path(rel_path_str_cli)
191+
192+
# For directory patterns ending with "/", check if this is a directory
178193
if pattern.endswith("/") and path_to_check_abs.is_dir():
179194
path_to_match_cli_dir = rel_path_str_cli
180195
if not path_to_match_cli_dir.endswith("/"):
@@ -183,6 +198,17 @@ def is_path_ignored(
183198
return True
184199
if current_path_for_cli_match.name + "/" == pattern:
185200
return True
201+
202+
# For directory patterns ending with "/", also check if any parent directory matches
203+
if pattern.endswith("/"):
204+
# Check if any parent directory of the file matches the pattern
205+
for parent in current_path_for_cli_match.parents:
206+
parent_str = parent.as_posix()
207+
if parent_str + "/" == pattern:
208+
return True
209+
if parent.name + "/" == pattern:
210+
return True
211+
186212
if current_path_for_cli_match.match(pattern):
187213
return True
188214

tests/test_main.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ def test_flatten_command_success(mock_flatten_logic, tmp_path: Path):
135135
"""Test flatten command successful execution (mocking actual flattening)."""
136136
result = runner.invoke(app, ["flatten", str(tmp_path)])
137137
assert result.exit_code == 0
138-
mock_flatten_logic.assert_called_once_with(root_dir_path=tmp_path.resolve(), output_file_path=None, include_patterns=[], exclude_patterns=[])
138+
mock_flatten_logic.assert_called_once_with(
139+
root_dir_path=tmp_path.resolve(), output_file_path=None, include_patterns=[], exclude_patterns=[], config_global_excludes=[]
140+
)
139141

140142

141143
@mock.patch("src.contextcraft.tools.flattener.flatten_code_logic")
@@ -147,7 +149,11 @@ def test_flatten_command_with_options(mock_flatten_logic, tmp_path: Path):
147149
)
148150
assert result.exit_code == 0
149151
mock_flatten_logic.assert_called_once_with(
150-
root_dir_path=tmp_path.resolve(), output_file_path=output_f.resolve(), include_patterns=["*.py", "*.md"], exclude_patterns=["temp/*"]
152+
root_dir_path=tmp_path.resolve(),
153+
output_file_path=output_f.resolve(),
154+
include_patterns=["*.py", "*.md"],
155+
exclude_patterns=["temp/*"],
156+
config_global_excludes=[],
151157
)
152158

153159

@@ -308,8 +314,7 @@ def test_tree_uses_config_global_excludes(mock_generate_tree, tmp_path: Path):
308314
config_data = {"global_exclude_patterns": ["*.log", "build/"]}
309315
create_pyproject_with_config(tmp_path, config_data)
310316

311-
with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir:
312-
result = runner.invoke(app, ["tree", str(isolated_dir)])
317+
result = runner.invoke(app, ["tree", str(tmp_path)])
313318

314319
assert result.exit_code == 0
315320
mock_generate_tree.assert_called_once()
@@ -326,8 +331,7 @@ def test_tree_cli_ignore_augments_config_global_excludes(mock_generate_tree, tmp
326331
create_pyproject_with_config(tmp_path, config_data)
327332

328333
cli_ignore_val = "temp/"
329-
with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir:
330-
result = runner.invoke(app, ["tree", str(isolated_dir), "--ignore", cli_ignore_val])
334+
result = runner.invoke(app, ["tree", str(tmp_path), "--ignore", cli_ignore_val])
331335

332336
assert result.exit_code == 0
333337
mock_generate_tree.assert_called_once()
@@ -345,8 +349,7 @@ def test_flatten_uses_config_global_excludes(mock_flatten_logic, tmp_path: Path)
345349
config_data = {"global_exclude_patterns": ["__pycache__/", "*.tmp"]}
346350
create_pyproject_with_config(tmp_path, config_data)
347351

348-
with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir:
349-
result = runner.invoke(app, ["flatten", str(isolated_dir)])
352+
result = runner.invoke(app, ["flatten", str(tmp_path)])
350353

351354
assert result.exit_code == 0
352355
mock_flatten_logic.assert_called_once()
@@ -362,8 +365,7 @@ def test_flatten_cli_exclude_augments_config_global_excludes(mock_flatten_logic,
362365
create_pyproject_with_config(tmp_path, config_data)
363366

364367
cli_exclude_val = "*.css"
365-
with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir:
366-
result = runner.invoke(app, ["flatten", str(isolated_dir), "--exclude", cli_exclude_val])
368+
result = runner.invoke(app, ["flatten", str(tmp_path), "--exclude", cli_exclude_val])
367369

368370
assert result.exit_code == 0
369371
mock_flatten_logic.assert_called_once()
@@ -429,6 +431,8 @@ def test_flatten_integration_with_config_excludes(tmp_path: Path):
429431
assert "# --- File: utils.py ---" in content
430432
assert "print('utils')" in content
431433

432-
assert "ignored.py" not in content
433-
assert "docs/index.md" not in content # Check full path or unique content
434-
assert "# Docs" not in content
434+
# Check that the actual ignored files are not included (check for file headers)
435+
assert "# --- File: ignored.py ---" not in content
436+
assert "# --- File: docs/index.md ---" not in content
437+
assert "print('ignored')" not in content # Content from ignored.py
438+
assert "# Docs" not in content # Content from docs/index.md

0 commit comments

Comments
 (0)