llm-tool-cli 0.2.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.
- llm_tool_cli/__init__.py +0 -0
- llm_tool_cli/cli/__init__.py +1 -0
- llm_tool_cli/cli/application.py +31 -0
- llm_tool_cli/cli/commands/__init__.py +0 -0
- llm_tool_cli/cli/commands/initialization.py +36 -0
- llm_tool_cli/cli/commands/skills.py +25 -0
- llm_tool_cli/cli/commands/tests/__init__.py +0 -0
- llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
- llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
- llm_tool_cli/cli/commands/tests/test_version.py +98 -0
- llm_tool_cli/cli/commands/version.py +17 -0
- llm_tool_cli/cli/context.py +39 -0
- llm_tool_cli/cli/entities.py +3 -0
- llm_tool_cli/cli/errors.py +15 -0
- llm_tool_cli/cli/handling.py +41 -0
- llm_tool_cli/cli/options.py +48 -0
- llm_tool_cli/cli/tests/__init__.py +0 -0
- llm_tool_cli/cli/tests/test_application.py +167 -0
- llm_tool_cli/cli/tests/test_context.py +179 -0
- llm_tool_cli/cli/tests/test_handling.py +230 -0
- llm_tool_cli/cli/tests/test_options.py +125 -0
- llm_tool_cli/config/__init__.py +33 -0
- llm_tool_cli/config/errors.py +92 -0
- llm_tool_cli/config/fields.py +39 -0
- llm_tool_cli/config/files.py +175 -0
- llm_tool_cli/config/project.py +87 -0
- llm_tool_cli/config/runtime.py +14 -0
- llm_tool_cli/config/tests/__init__.py +0 -0
- llm_tool_cli/config/tests/fixtures.py +34 -0
- llm_tool_cli/config/tests/test_fields.py +124 -0
- llm_tool_cli/config/tests/test_files.py +826 -0
- llm_tool_cli/config/tests/test_project.py +257 -0
- llm_tool_cli/core/__init__.py +1 -0
- llm_tool_cli/core/context.py +50 -0
- llm_tool_cli/core/entities.py +35 -0
- llm_tool_cli/core/errors.py +97 -0
- llm_tool_cli/core/result.py +118 -0
- llm_tool_cli/core/settings.py +37 -0
- llm_tool_cli/core/tests/__init__.py +0 -0
- llm_tool_cli/core/tests/fixtures.py +13 -0
- llm_tool_cli/core/tests/test_context.py +152 -0
- llm_tool_cli/core/tests/test_entities.py +65 -0
- llm_tool_cli/core/tests/test_errors.py +211 -0
- llm_tool_cli/core/tests/test_result.py +256 -0
- llm_tool_cli/core/tests/test_settings.py +64 -0
- llm_tool_cli/output/__init__.py +3 -0
- llm_tool_cli/output/emitter.py +108 -0
- llm_tool_cli/output/errors.py +21 -0
- llm_tool_cli/output/journal.py +66 -0
- llm_tool_cli/output/tests/__init__.py +0 -0
- llm_tool_cli/output/tests/helpers.py +29 -0
- llm_tool_cli/output/tests/test_emitter.py +406 -0
- llm_tool_cli/output/tests/test_journal.py +141 -0
- llm_tool_cli/paths/__init__.py +51 -0
- llm_tool_cli/paths/enumeration.py +72 -0
- llm_tool_cli/paths/errors.py +42 -0
- llm_tool_cli/paths/filesystem.py +164 -0
- llm_tool_cli/paths/globs.py +124 -0
- llm_tool_cli/paths/normalization.py +58 -0
- llm_tool_cli/paths/tests/__init__.py +0 -0
- llm_tool_cli/paths/tests/test_enumeration.py +229 -0
- llm_tool_cli/paths/tests/test_filesystem.py +778 -0
- llm_tool_cli/paths/tests/test_globs.py +137 -0
- llm_tool_cli/paths/tests/test_normalization.py +163 -0
- llm_tool_cli/protocol/__init__.py +4 -0
- llm_tool_cli/protocol/cell_shortcuts.py +39 -0
- llm_tool_cli/protocol/entities.py +7 -0
- llm_tool_cli/protocol/errors.py +21 -0
- llm_tool_cli/protocol/journal.py +21 -0
- llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
- llm_tool_cli/protocol/logic_cells/base.py +41 -0
- llm_tool_cli/protocol/logic_cells/content.py +26 -0
- llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
- llm_tool_cli/protocol/logic_cells/journal.py +44 -0
- llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
- llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
- llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
- llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
- llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
- llm_tool_cli/protocol/logic_cells/warning.py +22 -0
- llm_tool_cli/protocol/output_cells/__init__.py +11 -0
- llm_tool_cli/protocol/output_cells/automation.py +14 -0
- llm_tool_cli/protocol/output_cells/base.py +65 -0
- llm_tool_cli/protocol/output_cells/human.py +24 -0
- llm_tool_cli/protocol/output_cells/journal.py +23 -0
- llm_tool_cli/protocol/output_cells/llm.py +25 -0
- llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
- llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
- llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
- llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
- llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
- llm_tool_cli/protocol/serialization.py +7 -0
- llm_tool_cli/protocol/tests/__init__.py +0 -0
- llm_tool_cli/protocol/tests/helpers.py +25 -0
- llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
- llm_tool_cli/protocol/tests/test_journal.py +44 -0
- llm_tool_cli/protocol/tests/test_serialization.py +33 -0
- llm_tool_cli/py.typed +0 -0
- llm_tool_cli/skills/__init__.py +6 -0
- llm_tool_cli/skills/errors.py +12 -0
- llm_tool_cli/skills/fixtures.py +14 -0
- llm_tool_cli/skills/tests/__init__.py +0 -0
- llm_tool_cli/skills/tests/test_fixtures.py +58 -0
- llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
- llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
- llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
- llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Public project-path failures."""
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.core import errors as core_errors
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EnvironmentError(core_errors.EnvironmentError):
|
|
7
|
+
"""Base for expected project-path failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidProjectPath(EnvironmentError):
|
|
11
|
+
"""An input cannot identify a path below the project root."""
|
|
12
|
+
|
|
13
|
+
code: str = "invalid_project_path"
|
|
14
|
+
message: str = "invalid project path `{error.path}`"
|
|
15
|
+
path: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PathResolutionFailed(EnvironmentError):
|
|
19
|
+
"""A filesystem path could not be resolved."""
|
|
20
|
+
|
|
21
|
+
code: str = "path_resolution_failed"
|
|
22
|
+
message: str = "could not resolve project path `{error.path}`: {error.reason}"
|
|
23
|
+
path: str
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InvalidGlobPattern(EnvironmentError):
|
|
28
|
+
"""A glob contains invalid capture syntax."""
|
|
29
|
+
|
|
30
|
+
code: str = "invalid_glob_pattern"
|
|
31
|
+
message: str = "invalid glob pattern `{error.pattern}`: {error.reason}"
|
|
32
|
+
pattern: str
|
|
33
|
+
reason: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FilesUnreadable(EnvironmentError):
|
|
37
|
+
"""Project files could not be enumerated."""
|
|
38
|
+
|
|
39
|
+
code: str = "files_unreadable"
|
|
40
|
+
message: str = "could not discover files under `{error.path}`: {error.reason}"
|
|
41
|
+
path: str
|
|
42
|
+
reason: str
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Filesystem resolution and containment of project paths."""
|
|
2
|
+
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NewType
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
|
|
8
|
+
from llm_tool_cli.paths.errors import InvalidProjectPath, PathResolutionFailed
|
|
9
|
+
from llm_tool_cli.paths.normalization import ProjectPathId, normalize_project_path_id, project_path_parts
|
|
10
|
+
|
|
11
|
+
PathInput = NewType("PathInput", Path)
|
|
12
|
+
ProjectConfigPath = NewType("ProjectConfigPath", Path)
|
|
13
|
+
ProjectRootPath = NewType("ProjectRootPath", Path)
|
|
14
|
+
RelativeProjectPath = NewType("RelativeProjectPath", Path)
|
|
15
|
+
ResolvedProjectPath = NewType("ResolvedProjectPath", Path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@unwrap_to_error
|
|
19
|
+
def _project_path_input(value: str, root: ProjectRootPath, *, allow_absolute: bool = True) -> Result[PathInput]:
|
|
20
|
+
"""Select a filesystem candidate without interpreting its target."""
|
|
21
|
+
if value.startswith("@"):
|
|
22
|
+
normalized = normalize_project_path_id(value).unwrap()
|
|
23
|
+
return Ok(PathInput(root.joinpath(*project_path_parts(normalized))))
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
path = Path(value).expanduser()
|
|
27
|
+
except (OSError, RuntimeError) as error:
|
|
28
|
+
return Err([PathResolutionFailed(path=value, reason=str(error)).with_cause(error)])
|
|
29
|
+
|
|
30
|
+
if path.is_absolute() and not allow_absolute:
|
|
31
|
+
return Err([InvalidProjectPath(path=value)])
|
|
32
|
+
|
|
33
|
+
return Ok(PathInput(path if path.is_absolute() else root / path))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@unwrap_to_error
|
|
37
|
+
def resolve_project_path(value: str, root: Path, *, allow_absolute: bool = True) -> Result[ResolvedProjectPath]:
|
|
38
|
+
"""Resolve an identifier or filesystem input strictly below a project root.
|
|
39
|
+
|
|
40
|
+
Resolve the root first. Normalize identifiers before checking for symlinks;
|
|
41
|
+
expand home markers only in filesystem inputs. Relative filesystem inputs
|
|
42
|
+
use the resolved root. When absolute inputs are disabled, reject paths
|
|
43
|
+
that are absolute after home expansion. Do not require an existing target.
|
|
44
|
+
"""
|
|
45
|
+
project_root = resolve_project_root(root).unwrap()
|
|
46
|
+
candidate = _project_path_input(value, project_root, allow_absolute=allow_absolute).unwrap()
|
|
47
|
+
return resolve_inside_project(candidate, project_root)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@unwrap_to_error
|
|
51
|
+
def normalize_path(value: str, root: Path, *, cwd: Path | None = None) -> Result[ProjectPathId]:
|
|
52
|
+
"""Normalize an identifier or filesystem input to a project identifier.
|
|
53
|
+
|
|
54
|
+
Resolve the root first and reject empty inputs. Normalize inputs starting
|
|
55
|
+
with ``@`` lexically; otherwise expand home markers. Enforce containment
|
|
56
|
+
and reject symlinks below the root in both cases. Relative inputs use the
|
|
57
|
+
supplied directory base, or the resolved root.
|
|
58
|
+
Neither form requires an existing target.
|
|
59
|
+
"""
|
|
60
|
+
project_root = resolve_project_root(root).unwrap()
|
|
61
|
+
|
|
62
|
+
if not value:
|
|
63
|
+
return Err([InvalidProjectPath(path=value)])
|
|
64
|
+
|
|
65
|
+
if value.startswith("@"):
|
|
66
|
+
resolved = resolve_root_anchored_path(value, project_root).unwrap()
|
|
67
|
+
return Ok(project_path_id_from_resolved(resolved, project_root))
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
path = Path(value).expanduser()
|
|
71
|
+
except (OSError, RuntimeError) as error:
|
|
72
|
+
return Err([PathResolutionFailed(path=value, reason=str(error)).with_cause(error)])
|
|
73
|
+
|
|
74
|
+
candidate = path if path.is_absolute() else (cwd if cwd is not None else project_root) / path
|
|
75
|
+
resolved = resolve_inside_project(candidate, project_root).unwrap()
|
|
76
|
+
return Ok(project_path_id_from_resolved(resolved, project_root))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@unwrap_to_error
|
|
80
|
+
def project_path_id_from_filesystem(path: Path, root: Path) -> Result[ProjectPathId]:
|
|
81
|
+
"""Resolve a filesystem path and convert it to a project identifier.
|
|
82
|
+
|
|
83
|
+
Resolve the supplied root first, then enforce containment strictly below it.
|
|
84
|
+
Relative roots and paths use the process working directory. Do not expand
|
|
85
|
+
home markers, interpret identifiers, or require an existing target.
|
|
86
|
+
Preserve root and target resolution failures and containment diagnostics.
|
|
87
|
+
"""
|
|
88
|
+
project_root = resolve_project_root(root).unwrap()
|
|
89
|
+
resolved = resolve_inside_project(path, project_root).unwrap()
|
|
90
|
+
return Ok(project_path_id_from_resolved(resolved, project_root))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def project_path_id_from_resolved(path: ResolvedProjectPath, root: ProjectRootPath) -> ProjectPathId:
|
|
94
|
+
"""Convert a resolved path strictly below its resolved root to an identifier.
|
|
95
|
+
|
|
96
|
+
The path must already have passed containment resolution for this root.
|
|
97
|
+
Preserve segment text without filesystem access or repeated validation.
|
|
98
|
+
"""
|
|
99
|
+
return ProjectPathId("@/" + path.relative_to(root).as_posix())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_project_root(root: Path) -> Result[ProjectRootPath]:
|
|
103
|
+
"""Resolve a filesystem root without requiring an existing directory.
|
|
104
|
+
|
|
105
|
+
Relative roots use the process working directory. Resolve symlinks and
|
|
106
|
+
parent segments without expanding home markers. Return filesystem
|
|
107
|
+
resolution failures with their original cause.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
return Ok(ProjectRootPath(root.resolve()))
|
|
111
|
+
except (OSError, RuntimeError) as error:
|
|
112
|
+
return Err([PathResolutionFailed(path=str(root), reason=str(error)).with_cause(error)])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_no_symlinks(path: Path, root: ProjectRootPath) -> Result[None]: # noqa: CCR001
|
|
116
|
+
"""Reject links below the resolved root before resolving the target."""
|
|
117
|
+
candidate = path.absolute()
|
|
118
|
+
current = Path(candidate.anchor)
|
|
119
|
+
for part in candidate.parts[1:]:
|
|
120
|
+
current = current.parent if part == ".." else current / part
|
|
121
|
+
if current == root or not current.is_relative_to(root):
|
|
122
|
+
# Root aliases and symlinks above the chosen root remain supported.
|
|
123
|
+
current = current.resolve()
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
mode = current.lstat().st_mode
|
|
127
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
128
|
+
continue
|
|
129
|
+
if stat.S_ISLNK(mode):
|
|
130
|
+
return Err([InvalidProjectPath(path=str(path))])
|
|
131
|
+
return Ok(None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@unwrap_to_error
|
|
135
|
+
def resolve_inside_project(path: Path, root: ProjectRootPath) -> Result[ResolvedProjectPath]:
|
|
136
|
+
"""Resolve a filesystem path and require it to be strictly below the root.
|
|
137
|
+
|
|
138
|
+
The root must already be resolved. Relative paths use the process working
|
|
139
|
+
directory; callers choose any other base before calling. Do not expand
|
|
140
|
+
home markers or require an existing target.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
_validate_no_symlinks(path, root).unwrap()
|
|
144
|
+
resolved = path.resolve()
|
|
145
|
+
except (OSError, RuntimeError) as error:
|
|
146
|
+
return Err([PathResolutionFailed(path=str(path), reason=str(error)).with_cause(error)])
|
|
147
|
+
|
|
148
|
+
if resolved == root or not resolved.is_relative_to(root):
|
|
149
|
+
return Err([InvalidProjectPath(path=str(path))])
|
|
150
|
+
|
|
151
|
+
return Ok(ResolvedProjectPath(resolved))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@unwrap_to_error
|
|
155
|
+
def resolve_root_anchored_path(value: str, root: ProjectRootPath) -> Result[ResolvedProjectPath]:
|
|
156
|
+
"""Normalize an ``@/`` identifier and resolve it strictly below the root.
|
|
157
|
+
|
|
158
|
+
The root must already be resolved. Normalize identifier segments before
|
|
159
|
+
resolving symlinks and checking containment, without requiring an existing
|
|
160
|
+
target. Preserve lexical and filesystem resolution diagnostics.
|
|
161
|
+
"""
|
|
162
|
+
normalized = normalize_project_path_id(value).unwrap()
|
|
163
|
+
path = root.joinpath(*project_path_parts(normalized))
|
|
164
|
+
return resolve_inside_project(path, root)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Shared project glob preparation, matching, and named captures."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import glob
|
|
5
|
+
import os.path
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NewType
|
|
9
|
+
|
|
10
|
+
from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
|
|
11
|
+
from llm_tool_cli.paths.errors import InvalidGlobPattern, InvalidProjectPath, PathResolutionFailed
|
|
12
|
+
from llm_tool_cli.paths.filesystem import (
|
|
13
|
+
ProjectRootPath,
|
|
14
|
+
_project_path_input,
|
|
15
|
+
_validate_no_symlinks,
|
|
16
|
+
resolve_project_root,
|
|
17
|
+
)
|
|
18
|
+
from llm_tool_cli.paths.normalization import ProjectPathId, project_path_parts
|
|
19
|
+
|
|
20
|
+
FileGlobPattern = NewType("FileGlobPattern", str)
|
|
21
|
+
"""A supplied filesystem glob without validity or containment guarantees."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@unwrap_to_error
|
|
25
|
+
def _pattern_parts(pattern: FileGlobPattern, root: ProjectRootPath) -> Result[tuple[str, ...]]:
|
|
26
|
+
"""Resolve the literal prefix without treating glob syntax as filenames."""
|
|
27
|
+
candidate = _project_path_input(pattern, root).unwrap()
|
|
28
|
+
parts = candidate.parts
|
|
29
|
+
start = len(root.parts) if candidate.is_relative_to(root) else 0
|
|
30
|
+
first_magic = next((index for index in range(start, len(parts)) if glob.has_magic(parts[index])), len(parts))
|
|
31
|
+
prefix = Path(*parts[:first_magic])
|
|
32
|
+
try:
|
|
33
|
+
_validate_no_symlinks(prefix, root).unwrap()
|
|
34
|
+
resolved = prefix.resolve().joinpath(*parts[first_magic:])
|
|
35
|
+
except (OSError, RuntimeError) as error:
|
|
36
|
+
return Err([PathResolutionFailed(path=str(candidate), reason=str(error)).with_cause(error)])
|
|
37
|
+
normalized = Path(os.path.normpath(resolved))
|
|
38
|
+
if normalized == root or not normalized.is_relative_to(root):
|
|
39
|
+
return Err([InvalidProjectPath(path=str(candidate))])
|
|
40
|
+
return Ok(normalized.relative_to(root).parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _compile_glob(pattern: FileGlobPattern) -> Result[tuple[re.Pattern[str], bool]]: # noqa: CCR001, TAE002
|
|
44
|
+
"""Compile path syntax and report whether it can cross directory boundaries."""
|
|
45
|
+
regex: list[str] = []
|
|
46
|
+
recursive = False
|
|
47
|
+
index = 0
|
|
48
|
+
while index < len(pattern):
|
|
49
|
+
character = pattern[index]
|
|
50
|
+
if character == "[":
|
|
51
|
+
end = index + 1
|
|
52
|
+
if end < len(pattern) and pattern[end] == "!":
|
|
53
|
+
end += 1
|
|
54
|
+
if end < len(pattern) and pattern[end] == "]":
|
|
55
|
+
end += 1
|
|
56
|
+
end = pattern.find("]", end)
|
|
57
|
+
if end != -1 and "/" not in pattern[index : end + 1]:
|
|
58
|
+
# Delegate character-class rules (including escaping and invalid
|
|
59
|
+
# ranges) to fnmatch, removing its whole-string wrapper.
|
|
60
|
+
translated = fnmatch.translate(pattern[index : end + 1])
|
|
61
|
+
regex.append("(?!/)" + translated[4:-3])
|
|
62
|
+
index = end + 1
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if character == "{" and pattern[index + 1 : index + 2] == "*":
|
|
66
|
+
end = pattern.find("}", index)
|
|
67
|
+
if end == -1:
|
|
68
|
+
return Err([InvalidGlobPattern(pattern=pattern, reason="unclosed capture")])
|
|
69
|
+
token = pattern[index + 1 : end]
|
|
70
|
+
wildcard = "**" if token.startswith("**") else "*"
|
|
71
|
+
name = token[len(wildcard) :]
|
|
72
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None:
|
|
73
|
+
return Err([InvalidGlobPattern(pattern=pattern, reason=f"invalid capture name `{name}`")])
|
|
74
|
+
index = end + 1
|
|
75
|
+
if wildcard == "**":
|
|
76
|
+
recursive = True
|
|
77
|
+
if pattern[index : index + 1] == "/":
|
|
78
|
+
regex.append(f"(?:(?P<{name}>.*)/)?")
|
|
79
|
+
index += 1
|
|
80
|
+
else:
|
|
81
|
+
regex.append(f"(?P<{name}>.*)")
|
|
82
|
+
else:
|
|
83
|
+
regex.append(f"(?P<{name}>[^/]*)")
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if pattern[index : index + 2] == "**":
|
|
87
|
+
recursive = True
|
|
88
|
+
index += 2
|
|
89
|
+
if pattern[index : index + 1] == "/":
|
|
90
|
+
regex.append("(?:.*/)?")
|
|
91
|
+
index += 1
|
|
92
|
+
else:
|
|
93
|
+
regex.append(".*")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
regex.append("[^/]*" if character == "*" else "[^/]" if character == "?" else re.escape(character))
|
|
97
|
+
index += 1
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
return Ok((re.compile("".join(regex), re.DOTALL), recursive))
|
|
101
|
+
except re.error as error:
|
|
102
|
+
return Err([InvalidGlobPattern(pattern=pattern, reason=str(error)).with_cause(error)])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@unwrap_to_error
|
|
106
|
+
def glob_capture_names(pattern: FileGlobPattern) -> Result[set[str]]:
|
|
107
|
+
"""Validate glob syntax and collect capture names without filesystem access."""
|
|
108
|
+
compiled, _ = _compile_glob(pattern).unwrap()
|
|
109
|
+
return Ok(set(compiled.groupindex))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@unwrap_to_error
|
|
113
|
+
def match_glob(path: ProjectPathId, pattern: FileGlobPattern, root: Path) -> Result[dict[str, str] | None]:
|
|
114
|
+
"""Match an already normalized identifier, including a missing reference.
|
|
115
|
+
|
|
116
|
+
Only the pattern's literal prefix is resolved against the filesystem.
|
|
117
|
+
Return captured strings (empty for omitted recursive directories), an empty
|
|
118
|
+
dictionary for a match without captures, or None for a non-match.
|
|
119
|
+
"""
|
|
120
|
+
project_root = resolve_project_root(root).unwrap()
|
|
121
|
+
parts = _pattern_parts(pattern, project_root).unwrap()
|
|
122
|
+
compiled, _ = _compile_glob(FileGlobPattern("/".join(parts))).unwrap()
|
|
123
|
+
match = compiled.fullmatch("/".join(project_path_parts(path)))
|
|
124
|
+
return Ok({name: value or "" for name, value in match.groupdict().items()} if match else None)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Lexical operations on project-path identifiers."""
|
|
2
|
+
|
|
3
|
+
from typing import NewType
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.core.result import Err, Ok, Result
|
|
6
|
+
from llm_tool_cli.paths.errors import InvalidProjectPath
|
|
7
|
+
|
|
8
|
+
ProjectPathId = NewType("ProjectPathId", str)
|
|
9
|
+
|
|
10
|
+
_PROJECT_ROOT_PREFIX = "@/"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_project_path_id(value: str) -> Result[ProjectPathId]:
|
|
14
|
+
"""Normalize a root-anchored identifier without accessing the filesystem.
|
|
15
|
+
|
|
16
|
+
Remove ``.`` and resolve ``..`` lexically. Reject missing ``@/``, empty
|
|
17
|
+
segments, traversal above the root, and a final root-only identifier with
|
|
18
|
+
``Err([InvalidProjectPath(...)])``. Preserve all other segment text.
|
|
19
|
+
"""
|
|
20
|
+
if not value.startswith(_PROJECT_ROOT_PREFIX):
|
|
21
|
+
return Err([InvalidProjectPath(path=value)])
|
|
22
|
+
|
|
23
|
+
parts: list[str] = []
|
|
24
|
+
for part in value.removeprefix(_PROJECT_ROOT_PREFIX).split("/"):
|
|
25
|
+
match part:
|
|
26
|
+
case "":
|
|
27
|
+
return Err([InvalidProjectPath(path=value)])
|
|
28
|
+
case ".":
|
|
29
|
+
continue
|
|
30
|
+
case "..":
|
|
31
|
+
if not parts:
|
|
32
|
+
return Err([InvalidProjectPath(path=value)])
|
|
33
|
+
parts.pop()
|
|
34
|
+
case _:
|
|
35
|
+
parts.append(part)
|
|
36
|
+
|
|
37
|
+
if not parts:
|
|
38
|
+
return Err([InvalidProjectPath(path=value)])
|
|
39
|
+
|
|
40
|
+
return Ok(ProjectPathId(_PROJECT_ROOT_PREFIX + "/".join(parts)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_project_path_id(value: object) -> bool:
|
|
44
|
+
"""Check whether an input is already a canonical project-path identifier.
|
|
45
|
+
|
|
46
|
+
Return false for non-strings and values requiring lexical normalization.
|
|
47
|
+
Do not access the filesystem or apply application-specific rules.
|
|
48
|
+
"""
|
|
49
|
+
return isinstance(value, str) and normalize_project_path_id(value).ok() == value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def project_path_parts(value: ProjectPathId) -> tuple[str, ...]:
|
|
53
|
+
"""Return the components of an already canonical project-path identifier.
|
|
54
|
+
|
|
55
|
+
The caller must establish canonical form before calling. Preserve segment
|
|
56
|
+
text without filesystem access or repeated normalization and validation.
|
|
57
|
+
"""
|
|
58
|
+
return tuple(value.removeprefix(_PROJECT_ROOT_PREFIX).split("/"))
|
|
File without changes
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest_mock import MockerFixture
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.paths import FileGlobPattern, ProjectPathId, enumerate_files, match_glob
|
|
9
|
+
from llm_tool_cli.paths.enumeration import _glob_files, _walk_files
|
|
10
|
+
from llm_tool_cli.paths.errors import FilesUnreadable, InvalidGlobPattern, InvalidProjectPath, PathResolutionFailed
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestWalkFiles:
|
|
14
|
+
def test_only_regular_files_without_following_links(self, tmp_path: Path) -> None:
|
|
15
|
+
(tmp_path / "nested").mkdir()
|
|
16
|
+
(tmp_path / "nested/file").touch()
|
|
17
|
+
(tmp_path / "cycle").symlink_to(tmp_path)
|
|
18
|
+
(tmp_path / "link").symlink_to(tmp_path / "nested/file")
|
|
19
|
+
(tmp_path / "broken").symlink_to(tmp_path / "missing")
|
|
20
|
+
|
|
21
|
+
assert list(_walk_files(tmp_path)) == [tmp_path / "nested/file"]
|
|
22
|
+
assert list(_walk_files(tmp_path / "missing")) == []
|
|
23
|
+
assert list(_walk_files(tmp_path / "nested/file/child")) == []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestGlobFiles:
|
|
27
|
+
def test_recursive_patterns_dedicated_components(self, tmp_path: Path) -> None:
|
|
28
|
+
(tmp_path / "nested").mkdir()
|
|
29
|
+
(tmp_path / "nested/a.py").touch()
|
|
30
|
+
(tmp_path / "b.py").touch()
|
|
31
|
+
(tmp_path / "broken.py").symlink_to(tmp_path / "absent")
|
|
32
|
+
|
|
33
|
+
assert sorted(_glob_files(tmp_path, ("**", "*.py"))) == [tmp_path / "b.py", tmp_path / "nested/a.py"]
|
|
34
|
+
|
|
35
|
+
def test_missing_or_non_directory_prefix_has_no_matches(self, tmp_path: Path) -> None:
|
|
36
|
+
(tmp_path / "file").touch()
|
|
37
|
+
|
|
38
|
+
assert list(_glob_files(tmp_path / "missing", ("*",))) == []
|
|
39
|
+
assert list(_glob_files(tmp_path / "file/child", ("*",))) == []
|
|
40
|
+
assert list(_glob_files(tmp_path / "file", ("*",))) == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestEnumerateFiles:
|
|
44
|
+
@pytest.mark.parametrize(
|
|
45
|
+
"pattern",
|
|
46
|
+
[
|
|
47
|
+
"**/*",
|
|
48
|
+
"**",
|
|
49
|
+
"**/**",
|
|
50
|
+
"src/[ab].py",
|
|
51
|
+
"src/[!b]*.py",
|
|
52
|
+
"src/a**.py",
|
|
53
|
+
"src/{*module}.py",
|
|
54
|
+
"src/{**module}.py",
|
|
55
|
+
"{**directory}/{*file}",
|
|
56
|
+
"src/star[*].py",
|
|
57
|
+
"src/**/a?.py",
|
|
58
|
+
"src/[[]literal].py",
|
|
59
|
+
"src/a.py/**",
|
|
60
|
+
"src/[**].py",
|
|
61
|
+
"src/[a/nested].py",
|
|
62
|
+
"src/Case.py",
|
|
63
|
+
"src/[!x]*.py",
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_enumeration_and_matching_share_the_same_grammar(self, tmp_path: Path, pattern: str) -> None:
|
|
67
|
+
names = [
|
|
68
|
+
"src/a.py",
|
|
69
|
+
"src/b.py",
|
|
70
|
+
"src/a/nested.py",
|
|
71
|
+
"src/.hidden/a1.py",
|
|
72
|
+
"src/.hidden.py",
|
|
73
|
+
"src/star*.py",
|
|
74
|
+
"src/[literal].py",
|
|
75
|
+
"src/line\nbreak.py",
|
|
76
|
+
"src/*.py",
|
|
77
|
+
"src/case.py",
|
|
78
|
+
"docs/a.py",
|
|
79
|
+
"root.txt",
|
|
80
|
+
]
|
|
81
|
+
for name in names:
|
|
82
|
+
path = tmp_path / name
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path.touch()
|
|
85
|
+
pattern_value = FileGlobPattern(pattern)
|
|
86
|
+
expected = [
|
|
87
|
+
"@/" + name
|
|
88
|
+
for name in sorted(names, key=lambda name: Path(name).parts)
|
|
89
|
+
if match_glob(ProjectPathId("@/" + name), pattern_value, tmp_path).unwrap() is not None
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
assert enumerate_files(tmp_path, pattern_value).unwrap() == expected
|
|
93
|
+
|
|
94
|
+
def test_recursive_captures_and_embedded_wildcards_select_nested_files(self, tmp_path: Path) -> None:
|
|
95
|
+
(tmp_path / "a").mkdir()
|
|
96
|
+
(tmp_path / "a/nested.py").touch()
|
|
97
|
+
(tmp_path / "a.py").touch()
|
|
98
|
+
|
|
99
|
+
for pattern in ["a**.py", "{**module}.py"]:
|
|
100
|
+
assert enumerate_files(tmp_path, FileGlobPattern(pattern)).unwrap() == ["@/a/nested.py", "@/a.py"]
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("pattern", ["{*bad-name}", "{*name}/{*name}", "{*unclosed"])
|
|
103
|
+
def test_invalid_captures_fail_even_in_an_empty_directory(self, tmp_path: Path, pattern: str) -> None:
|
|
104
|
+
assert enumerate_files(tmp_path, FileGlobPattern(pattern)).is_err(InvalidGlobPattern)
|
|
105
|
+
|
|
106
|
+
def test_nonmatching_directories_are_not_traversed(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
107
|
+
(tmp_path / "src").mkdir()
|
|
108
|
+
(tmp_path / "src/file.py").touch()
|
|
109
|
+
(tmp_path / "unrelated").mkdir()
|
|
110
|
+
iterdir = Path.iterdir
|
|
111
|
+
|
|
112
|
+
def read_directory(path: Path) -> Iterator[Path]:
|
|
113
|
+
if path == tmp_path / "unrelated":
|
|
114
|
+
raise PermissionError("must not traverse unrelated directory")
|
|
115
|
+
return iterdir(path)
|
|
116
|
+
|
|
117
|
+
mocker.patch.object(Path, "iterdir", autospec=True, side_effect=read_directory)
|
|
118
|
+
|
|
119
|
+
assert enumerate_files(tmp_path, FileGlobPattern("s?c/**/*.py")).unwrap() == ["@/src/file.py"]
|
|
120
|
+
|
|
121
|
+
def test_wildcards_do_not_resolve_literal_symlink_names(self, tmp_path: Path) -> None:
|
|
122
|
+
(tmp_path / "ordinary").touch()
|
|
123
|
+
(tmp_path / "*").symlink_to(tmp_path)
|
|
124
|
+
(tmp_path / "**").symlink_to(tmp_path)
|
|
125
|
+
|
|
126
|
+
assert enumerate_files(tmp_path).unwrap() == ["@/ordinary"]
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize("pattern", ["**/*", "@/**/*", "**", "**/**"])
|
|
129
|
+
def test_unique_files_in_component_order_including_hidden_entries(self, tmp_path: Path, pattern: str) -> None:
|
|
130
|
+
names = ["a/child", "a.txt", ".hidden/file", ".hidden-file", "z"]
|
|
131
|
+
for name in names:
|
|
132
|
+
path = tmp_path / name
|
|
133
|
+
path.parent.mkdir(exist_ok=True)
|
|
134
|
+
path.touch()
|
|
135
|
+
(tmp_path / "empty").mkdir()
|
|
136
|
+
|
|
137
|
+
assert enumerate_files(tmp_path, FileGlobPattern(pattern)).unwrap() == [
|
|
138
|
+
"@/.hidden/file",
|
|
139
|
+
"@/.hidden-file",
|
|
140
|
+
"@/a/child",
|
|
141
|
+
"@/a.txt",
|
|
142
|
+
"@/z",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
@pytest.mark.parametrize("pattern", ["**/*.py", "@/**/*.py", "**/a?.[pt][xy]", "**/[!b]*.py"])
|
|
146
|
+
def test_glob_syntax_and_zero_directory_recursion(self, tmp_path: Path, pattern: str) -> None:
|
|
147
|
+
(tmp_path / "nested").mkdir()
|
|
148
|
+
for name in ["a1.py", "nested/a2.py", "b.txt"]:
|
|
149
|
+
(tmp_path / name).touch()
|
|
150
|
+
|
|
151
|
+
assert enumerate_files(tmp_path, FileGlobPattern(pattern)).unwrap() == ["@/a1.py", "@/nested/a2.py"]
|
|
152
|
+
|
|
153
|
+
@pytest.mark.parametrize("pattern", ["**/*", "**", "*.py", "*/**/*"])
|
|
154
|
+
def test_skips_all_discovered_links(self, tmp_path: Path, pattern: str) -> None:
|
|
155
|
+
project = tmp_path / "project"
|
|
156
|
+
project.mkdir()
|
|
157
|
+
target = tmp_path / "outside"
|
|
158
|
+
target.mkdir()
|
|
159
|
+
(target / "outside.py").touch()
|
|
160
|
+
(project / "ordinary.py").touch()
|
|
161
|
+
for name, destination in [
|
|
162
|
+
("file.py", project / "ordinary.py"),
|
|
163
|
+
("external.py", target / "outside.py"),
|
|
164
|
+
("directory", target),
|
|
165
|
+
("cycle", project),
|
|
166
|
+
("broken.py", project / "missing"),
|
|
167
|
+
]:
|
|
168
|
+
(project / name).symlink_to(destination)
|
|
169
|
+
|
|
170
|
+
expected = [] if pattern == "*/**/*" else ["@/ordinary.py"]
|
|
171
|
+
assert enumerate_files(project, FileGlobPattern(pattern)).unwrap() == expected
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize("suffix", ["", "/**/*", "/../ordinary"])
|
|
174
|
+
def test_rejects_explicit_symlink_prefix(self, tmp_path: Path, suffix: str) -> None:
|
|
175
|
+
(tmp_path / "link").symlink_to(tmp_path)
|
|
176
|
+
result = enumerate_files(tmp_path, FileGlobPattern("link" + suffix))
|
|
177
|
+
|
|
178
|
+
assert result.is_err(InvalidProjectPath)
|
|
179
|
+
|
|
180
|
+
def test_missing_directory_or_file_root_produces_no_files(self, tmp_path: Path) -> None:
|
|
181
|
+
(tmp_path / "file").touch()
|
|
182
|
+
|
|
183
|
+
assert enumerate_files(tmp_path, FileGlobPattern("missing/**/*")).unwrap() == []
|
|
184
|
+
assert enumerate_files(tmp_path / "missing").unwrap() == []
|
|
185
|
+
assert enumerate_files(tmp_path / "file").unwrap() == []
|
|
186
|
+
|
|
187
|
+
def test_literal_pattern_and_escaped_metacharacters(self, tmp_path: Path) -> None:
|
|
188
|
+
directory = tmp_path / "[work]*?"
|
|
189
|
+
directory.mkdir()
|
|
190
|
+
(directory / "file.py").touch()
|
|
191
|
+
|
|
192
|
+
assert enumerate_files(tmp_path, FileGlobPattern(glob.escape(directory.name) + "/file.py")).unwrap() == [
|
|
193
|
+
"@/[work]*?/file.py"
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
def test_absolute_and_home_patterns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
197
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
198
|
+
(tmp_path / "file.py").touch()
|
|
199
|
+
|
|
200
|
+
assert enumerate_files(tmp_path, FileGlobPattern(str(tmp_path / "*.py"))).unwrap() == ["@/file.py"]
|
|
201
|
+
assert enumerate_files(tmp_path, FileGlobPattern("~/*.py")).unwrap() == ["@/file.py"]
|
|
202
|
+
|
|
203
|
+
@pytest.mark.parametrize("pattern", ["../*", "@/../*", "@/", "@/a//b", "."])
|
|
204
|
+
def test_invalid_patterns_preserve_diagnostics(self, tmp_path: Path, pattern: str) -> None:
|
|
205
|
+
assert enumerate_files(tmp_path, FileGlobPattern(pattern)).is_err(InvalidProjectPath)
|
|
206
|
+
|
|
207
|
+
def test_root_resolution_failure_precedes_pattern_processing(self, tmp_path: Path) -> None:
|
|
208
|
+
root = tmp_path / "loop"
|
|
209
|
+
root.symlink_to(root)
|
|
210
|
+
|
|
211
|
+
assert enumerate_files(root, FileGlobPattern("@/../*")).is_err(PathResolutionFailed)
|
|
212
|
+
|
|
213
|
+
def test_directory_read_failure_is_not_an_empty_result(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
214
|
+
cause = PermissionError("cannot list directory")
|
|
215
|
+
mocker.patch.object(Path, "iterdir", side_effect=cause)
|
|
216
|
+
|
|
217
|
+
failure = enumerate_files(tmp_path).unwrap_err()[0]
|
|
218
|
+
|
|
219
|
+
assert isinstance(failure, FilesUnreadable)
|
|
220
|
+
assert failure.code == "files_unreadable"
|
|
221
|
+
assert failure.path == str(tmp_path)
|
|
222
|
+
assert failure.cause == cause
|
|
223
|
+
assert "cause" not in failure.as_record()
|
|
224
|
+
|
|
225
|
+
def test_unexpected_failure_remains_an_exception(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
226
|
+
mocker.patch.object(Path, "iterdir", side_effect=ValueError("unexpected failure"))
|
|
227
|
+
|
|
228
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
229
|
+
enumerate_files(tmp_path)
|