doc-code 0.1.0__py3-none-any.whl

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.
doc_code/scope.py ADDED
@@ -0,0 +1,105 @@
1
+ """File selection, including Git-aware filtering and repository scanning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator
7
+ from fnmatch import fnmatch
8
+ from pathlib import Path
9
+
10
+ from .config import Settings
11
+ from .errors import DocGubError, NoEligibleFilesError
12
+ from .git import GitRepo
13
+
14
+ SUPPORTED_SUFFIXES = {".py", ".js", ".jsx", ".ts", ".tsx"}
15
+ DEFAULT_EXCLUDED_PARTS = {".git", "node_modules", "dist", "build", ".venv", "venv", "__pycache__"}
16
+
17
+
18
+ def _matches(relative: str, patterns: tuple[str, ...]) -> bool:
19
+ """Match root and nested paths consistently for include and exclude patterns."""
20
+ normalized = relative.replace("\\", "/")
21
+ return any(
22
+ fnmatch(candidate, pattern)
23
+ for pattern in patterns
24
+ for candidate in (normalized, f"/{normalized}")
25
+ )
26
+
27
+
28
+ def _walk_candidates(root: Path, source: Path) -> Iterator[str]:
29
+ """Yield files while pruning excluded and symbolic-link directories early."""
30
+ for directory, subdirectories, filenames in os.walk(source, followlinks=False):
31
+ current = Path(directory)
32
+ subdirectories[:] = sorted(
33
+ name
34
+ for name in subdirectories
35
+ if name not in DEFAULT_EXCLUDED_PARTS and not (current / name).is_symlink()
36
+ )
37
+ for filename in sorted(filenames):
38
+ yield (current / filename).relative_to(root).as_posix()
39
+
40
+
41
+ def _is_oversized(repo: GitRepo, relative: str, settings: Settings) -> bool:
42
+ """Return whether an existing supported file exceeds the byte limit."""
43
+ path = repo.root / relative
44
+ try:
45
+ return (
46
+ path.is_file()
47
+ and path.suffix.lower() in SUPPORTED_SUFFIXES
48
+ and path.stat().st_size > settings.max_file_bytes
49
+ )
50
+ except OSError:
51
+ return False
52
+
53
+
54
+ def _eligible(repo: GitRepo, relative: str, settings: Settings) -> bool:
55
+ """Return whether a worktree-relative path satisfies every scope rule."""
56
+ path = repo.root / relative
57
+ if path.is_symlink() or not path.is_file() or path.suffix.lower() not in SUPPORTED_SUFFIXES:
58
+ return False
59
+ try:
60
+ path.resolve(strict=True).relative_to(repo.root.resolve(strict=True))
61
+ except (OSError, ValueError):
62
+ return False
63
+ if _is_oversized(repo, relative, settings):
64
+ return False
65
+ if any(part in DEFAULT_EXCLUDED_PARTS for part in Path(relative).parts):
66
+ return False
67
+ if _matches(relative, settings.exclude):
68
+ return False
69
+ return not settings.include or _matches(relative, settings.include)
70
+
71
+
72
+ def resolve(repo: GitRepo, requested: list[Path] | None, settings: Settings) -> list[str]:
73
+ """Resolve paths, Git changes, or a repository into a deduplicated file scope."""
74
+ if requested:
75
+ candidates: list[str] = []
76
+ for requested_path in requested:
77
+ relative = repo.relative_path(requested_path)
78
+ source = repo.root / relative
79
+ if source.is_file():
80
+ candidates.append(relative)
81
+ else:
82
+ candidates.extend(_walk_candidates(repo.root, source))
83
+ elif settings.selection == "changes":
84
+ candidates = repo.changed_files()
85
+ else:
86
+ candidates = list(_walk_candidates(repo.root, repo.root))
87
+ unique_candidates = set(candidates)
88
+ ignored_candidates = repo.ignored_paths(unique_candidates)
89
+ files = sorted(
90
+ item
91
+ for item in unique_candidates
92
+ if item not in ignored_candidates and _eligible(repo, item, settings)
93
+ )
94
+ if not files:
95
+ oversized = sorted(
96
+ item for item in unique_candidates if _is_oversized(repo, item, settings)
97
+ )
98
+ if oversized:
99
+ raise DocGubError(f"No eligible files: exceeds max_file_bytes: {', '.join(oversized)}.")
100
+ raise NoEligibleFilesError("No eligible Python, JavaScript, or TypeScript files found.")
101
+ if len(files) > settings.max_files_per_request:
102
+ raise DocGubError(
103
+ "The scope exceeds max_files_per_request; narrow the path or increase the limit."
104
+ )
105
+ return files