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,137 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pytest_mock import MockerFixture
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.paths import FileGlobPattern, ProjectPathId, ProjectRootPath, glob_capture_names, match_glob
|
|
7
|
+
from llm_tool_cli.paths.errors import InvalidGlobPattern, InvalidProjectPath, PathResolutionFailed
|
|
8
|
+
from llm_tool_cli.paths.globs import _compile_glob, _pattern_parts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestPatternParts:
|
|
12
|
+
def test_resolves_literal_prefix_before_normalizing_remaining_parts(self, tmp_path: Path) -> None:
|
|
13
|
+
assert _pattern_parts(FileGlobPattern("nested/../**/*.py"), ProjectRootPath(tmp_path)).unwrap() == (
|
|
14
|
+
"**",
|
|
15
|
+
"*.py",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def test_root_metacharacters_are_literal(self, tmp_path: Path) -> None:
|
|
19
|
+
root = tmp_path / "[project]*?"
|
|
20
|
+
root.mkdir()
|
|
21
|
+
(root / "link").symlink_to(root)
|
|
22
|
+
|
|
23
|
+
assert _pattern_parts(FileGlobPattern("link/*.py"), ProjectRootPath(root)).is_err(InvalidProjectPath)
|
|
24
|
+
|
|
25
|
+
def test_resolution_failure_retains_the_cause(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
26
|
+
cause = PermissionError("denied")
|
|
27
|
+
mocker.patch.object(Path, "resolve", side_effect=cause)
|
|
28
|
+
|
|
29
|
+
failure = _pattern_parts(FileGlobPattern("src/*.py"), ProjectRootPath(tmp_path)).unwrap_err()[0]
|
|
30
|
+
|
|
31
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
32
|
+
assert failure.path == str(tmp_path / "src/*.py")
|
|
33
|
+
assert failure.cause == cause
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize("pattern", ["", ".", "../*", "@/../*", "@/", "@/a//b"])
|
|
36
|
+
def test_invalid_path_is_not_a_pattern(self, tmp_path: Path, pattern: str) -> None:
|
|
37
|
+
assert _pattern_parts(FileGlobPattern(pattern), ProjectRootPath(tmp_path)).is_err(InvalidProjectPath)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestCompileGlob:
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
("pattern", "matching", "nonmatching", "recursive"),
|
|
43
|
+
[
|
|
44
|
+
("*.py", "a.py", "dir/a.py", False),
|
|
45
|
+
("a?.py", "a1.py", "a/1.py", False),
|
|
46
|
+
("[ab].py", "a.py", "c.py", False),
|
|
47
|
+
("[!a].py", "b.py", "a.py", False),
|
|
48
|
+
("[a-z].py", "b.py", "B.py", False),
|
|
49
|
+
("star[*].py", "star*.py", "starfile.py", False),
|
|
50
|
+
("[[]file].py", "[file].py", "file.py", False),
|
|
51
|
+
("[]].py", "].py", "a.py", False),
|
|
52
|
+
("[**].py", "*.py", "dir/a.py", False),
|
|
53
|
+
("[abc", "[abc", "a", False),
|
|
54
|
+
("[a/b]", "[a/b]", "a", False),
|
|
55
|
+
("**/*.py", "a.py", "a.txt", True),
|
|
56
|
+
("a**.py", "a/deep/file.py", "b.py", True),
|
|
57
|
+
("**", "line\nbreak.py", "", True),
|
|
58
|
+
("{variable}", "{variable}", "value", False),
|
|
59
|
+
("a\\b", "a\\b", "a/b", False),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
def test_wildcard_semantics(self, pattern: str, matching: str, nonmatching: str, recursive: bool) -> None:
|
|
63
|
+
compiled, actual_recursive = _compile_glob(FileGlobPattern(pattern)).unwrap()
|
|
64
|
+
|
|
65
|
+
assert compiled.fullmatch(matching) is not None
|
|
66
|
+
if nonmatching:
|
|
67
|
+
assert compiled.fullmatch(nonmatching) is None
|
|
68
|
+
assert actual_recursive == recursive
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("pattern", ["{*bad-name}", "{**}", "{***name}", "{*unclosed", "{*x}/{**x}"])
|
|
71
|
+
def test_invalid_captures_return_diagnostics(self, pattern: str) -> None:
|
|
72
|
+
failure = _compile_glob(FileGlobPattern(pattern)).unwrap_err()[0]
|
|
73
|
+
|
|
74
|
+
assert isinstance(failure, InvalidGlobPattern)
|
|
75
|
+
assert failure.code == "invalid_glob_pattern"
|
|
76
|
+
assert failure.pattern == pattern
|
|
77
|
+
assert failure.reason
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestGlobCaptureNames:
|
|
81
|
+
def test_extracts_captures_without_interpreting_templates_or_escaped_stars(self) -> None:
|
|
82
|
+
assert glob_capture_names(FileGlobPattern("{base}/{**package}/{*module}.py")).unwrap() == {
|
|
83
|
+
"package",
|
|
84
|
+
"module",
|
|
85
|
+
}
|
|
86
|
+
assert glob_capture_names(FileGlobPattern("{[*]literal}.py")).unwrap() == set()
|
|
87
|
+
assert glob_capture_names(FileGlobPattern("")).unwrap() == set()
|
|
88
|
+
|
|
89
|
+
def test_invalid_capture_names_are_rejected(self) -> None:
|
|
90
|
+
assert glob_capture_names(FileGlobPattern("{*bad-name}")).is_err(InvalidGlobPattern)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestMatchGlob:
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
("pattern", "path", "expected"),
|
|
96
|
+
[
|
|
97
|
+
("src/{**package}/{*module}.py", "@/src/core/api.py", {"package": "core", "module": "api"}),
|
|
98
|
+
("src/{**package}/{*module}.py", "@/src/api.py", {"package": "", "module": "api"}),
|
|
99
|
+
("src/{**module_path}.py", "@/src/core/api.py", {"module_path": "core/api"}),
|
|
100
|
+
("src/{**module_path}.py", "@/src/api.py", {"module_path": "api"}),
|
|
101
|
+
("src/{*module}.py", "@/src/core/api.py", None),
|
|
102
|
+
("src/{*module}.py", "@/src/.py", {"module": ""}),
|
|
103
|
+
("src/{**package}/{*module}.py", "@/src/one/two/api.py", {"package": "one/two", "module": "api"}),
|
|
104
|
+
("src/*.py", "@/src/file.py", {}),
|
|
105
|
+
("src/**", "@/src", None),
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
def test_matches_missing_references(
|
|
109
|
+
self, tmp_path: Path, pattern: str, path: str, expected: dict[str, str] | None
|
|
110
|
+
) -> None:
|
|
111
|
+
assert match_glob(ProjectPathId(path), FileGlobPattern(pattern), tmp_path).unwrap() == expected
|
|
112
|
+
|
|
113
|
+
def test_does_not_resolve_glob_syntax_as_a_symlink(self, tmp_path: Path) -> None:
|
|
114
|
+
(tmp_path / "src").mkdir()
|
|
115
|
+
(tmp_path / "src/*.py").symlink_to(tmp_path / "missing")
|
|
116
|
+
|
|
117
|
+
assert match_glob(ProjectPathId("@/src/a.py"), FileGlobPattern("@/src/*.py"), tmp_path).unwrap() == {}
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("pattern", ["link/*.py", "link/../*.py", "@/link/*.py"])
|
|
120
|
+
def test_literal_symlink_prefix_is_rejected(self, tmp_path: Path, pattern: str) -> None:
|
|
121
|
+
(tmp_path / "link").symlink_to(tmp_path)
|
|
122
|
+
|
|
123
|
+
assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern(pattern), tmp_path).is_err(InvalidProjectPath)
|
|
124
|
+
|
|
125
|
+
def test_root_resolution_precedes_pattern_validation(self, tmp_path: Path) -> None:
|
|
126
|
+
root = tmp_path / "loop"
|
|
127
|
+
root.symlink_to(root)
|
|
128
|
+
|
|
129
|
+
assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern("@/../*"), root).is_err(PathResolutionFailed)
|
|
130
|
+
|
|
131
|
+
def test_absolute_and_home_patterns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
132
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
133
|
+
for pattern in [str(tmp_path / "*.py"), "~/*.py"]:
|
|
134
|
+
assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern(pattern), tmp_path).unwrap() == {}
|
|
135
|
+
|
|
136
|
+
def test_syntax_failures_are_preserved(self, tmp_path: Path) -> None:
|
|
137
|
+
assert match_glob(ProjectPathId("@/a.py"), FileGlobPattern("{*bad-name}"), tmp_path).is_err(InvalidGlobPattern)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.paths import is_project_path_id, normalize_project_path_id, project_path_parts
|
|
6
|
+
from llm_tool_cli.paths.errors import InvalidProjectPath
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestProjectPathParts:
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
("value", "expected"),
|
|
12
|
+
[
|
|
13
|
+
("@/LICENSE", ("LICENSE",)),
|
|
14
|
+
("@/notes/Project Plan.md", ("notes", "Project Plan.md")),
|
|
15
|
+
("@/Case/FILE", ("Case", "FILE")),
|
|
16
|
+
("@/café/資料", ("café", "資料")),
|
|
17
|
+
("@/ directory / file ", (" directory ", " file ")),
|
|
18
|
+
("@/a/.../b", ("a", "...", "b")),
|
|
19
|
+
("@/~/file", ("~", "file")),
|
|
20
|
+
("@/a\\b/file", ("a\\b", "file")),
|
|
21
|
+
("@/name:section", ("name:section",)),
|
|
22
|
+
("@/src/{**package}/*.[pP][yY]", ("src", "{**package}", "*.[pP][yY]")),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
def test_extracts_components(self, value: str, expected: tuple[str, ...]) -> None:
|
|
26
|
+
identifier = normalize_project_path_id(value).unwrap()
|
|
27
|
+
|
|
28
|
+
assert project_path_parts(identifier) == expected
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestIsProjectPathId:
|
|
32
|
+
@pytest.mark.parametrize(
|
|
33
|
+
"value",
|
|
34
|
+
[
|
|
35
|
+
"@/README.md",
|
|
36
|
+
"@/README",
|
|
37
|
+
"@/LICENSE",
|
|
38
|
+
"@/Makefile",
|
|
39
|
+
"@/assets",
|
|
40
|
+
"@/assets/archive",
|
|
41
|
+
"@/src/package",
|
|
42
|
+
"@/notes/Проектный план",
|
|
43
|
+
"@/notes/project plan.md",
|
|
44
|
+
"@/Case/FILE",
|
|
45
|
+
"@/ directory / file ",
|
|
46
|
+
"@/a/.../b",
|
|
47
|
+
"@/~/file",
|
|
48
|
+
"@/a\\b/file",
|
|
49
|
+
"@/name:section",
|
|
50
|
+
"@/src/{**package_path}/*.[pP][yY]",
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
def test_accepts_canonical_project_paths(self, value: str) -> None:
|
|
54
|
+
assert is_project_path_id(value)
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize(
|
|
57
|
+
"value",
|
|
58
|
+
[
|
|
59
|
+
"",
|
|
60
|
+
"@",
|
|
61
|
+
"@file",
|
|
62
|
+
"file",
|
|
63
|
+
"./file",
|
|
64
|
+
"/project/file",
|
|
65
|
+
" @/file",
|
|
66
|
+
"@/",
|
|
67
|
+
"@/.",
|
|
68
|
+
"@/..",
|
|
69
|
+
"@//file",
|
|
70
|
+
"@/a//b",
|
|
71
|
+
"@/a/",
|
|
72
|
+
"@/a/..",
|
|
73
|
+
"@/../a",
|
|
74
|
+
"@/a/../../a",
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
def test_rejects_malformed_project_paths(self, value: str) -> None:
|
|
78
|
+
assert not is_project_path_id(value)
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("value", ["@/./a", "@/a/.", "@/a/../b", "@/a/b/.."])
|
|
81
|
+
def test_rejects_paths_requiring_normalization(self, value: str) -> None:
|
|
82
|
+
assert not is_project_path_id(value)
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("value", [None, 1, b"@/file", [], {}, Path("@/file")])
|
|
85
|
+
def test_rejects_non_strings(self, value: object) -> None:
|
|
86
|
+
assert not is_project_path_id(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestNormalizeProjectPathId:
|
|
90
|
+
@pytest.mark.parametrize(
|
|
91
|
+
("value", "expected"),
|
|
92
|
+
[
|
|
93
|
+
("@/file", "@/file"),
|
|
94
|
+
("@/a/./b/../c", "@/a/c"),
|
|
95
|
+
("@/./a", "@/a"),
|
|
96
|
+
("@/a/.", "@/a"),
|
|
97
|
+
("@/a/b/..", "@/a"),
|
|
98
|
+
("@/a/../b", "@/b"),
|
|
99
|
+
("@/a/b/../../c", "@/c"),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
def test_normalization(self, value: str, expected: str) -> None:
|
|
103
|
+
normalized = normalize_project_path_id(value).unwrap()
|
|
104
|
+
|
|
105
|
+
assert normalized == expected
|
|
106
|
+
assert normalize_project_path_id(normalized).unwrap() == normalized
|
|
107
|
+
|
|
108
|
+
@pytest.mark.parametrize(
|
|
109
|
+
"value",
|
|
110
|
+
[
|
|
111
|
+
"",
|
|
112
|
+
"@",
|
|
113
|
+
"@file",
|
|
114
|
+
"file",
|
|
115
|
+
"./file",
|
|
116
|
+
"/file",
|
|
117
|
+
" @/file",
|
|
118
|
+
"@/",
|
|
119
|
+
"@/.",
|
|
120
|
+
"@/..",
|
|
121
|
+
"@//file",
|
|
122
|
+
"@/a//b",
|
|
123
|
+
"@/a/",
|
|
124
|
+
"@/a/..",
|
|
125
|
+
"@/a/../.",
|
|
126
|
+
"@/../a",
|
|
127
|
+
"@/a/../../a",
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
def test_invalid_path(self, value: str) -> None:
|
|
131
|
+
errors = normalize_project_path_id(value).unwrap_err()
|
|
132
|
+
|
|
133
|
+
assert len(errors) == 1
|
|
134
|
+
error = errors[0]
|
|
135
|
+
assert isinstance(error, InvalidProjectPath)
|
|
136
|
+
assert error.code == "invalid_project_path"
|
|
137
|
+
assert error.path == value.strip()
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"value",
|
|
141
|
+
[
|
|
142
|
+
"@/Case/FILE",
|
|
143
|
+
"@/café/資料",
|
|
144
|
+
"@/ directory / file ",
|
|
145
|
+
"@/a/.../b",
|
|
146
|
+
"@/~/file",
|
|
147
|
+
"@/a\\b/file",
|
|
148
|
+
"@/name:section",
|
|
149
|
+
"@/src/{**package_path}/*.[pP][yY]",
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
def test_preserves_segment_text(self, value: str) -> None:
|
|
153
|
+
assert normalize_project_path_id(value).unwrap() == value
|
|
154
|
+
|
|
155
|
+
def test_structured_error(self) -> None:
|
|
156
|
+
error = normalize_project_path_id("@/../file").unwrap_err()[0]
|
|
157
|
+
|
|
158
|
+
assert error.as_record() == {
|
|
159
|
+
"type": "error",
|
|
160
|
+
"code": "invalid_project_path",
|
|
161
|
+
"message": error.format_message(),
|
|
162
|
+
"path": "@/../file",
|
|
163
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from llm_tool_cli.core.errors import EnvironmentError
|
|
2
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
3
|
+
from llm_tool_cli.protocol.logic_cells import ContentCell, EnvironmentErrorCell
|
|
4
|
+
from llm_tool_cli.protocol.output_cells.base import MetaValue
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def operation_succeeded(message: str, **meta: MetaValue) -> ContentCell:
|
|
8
|
+
return ContentCell(
|
|
9
|
+
kind="operation_succeeded",
|
|
10
|
+
media_type="text/markdown",
|
|
11
|
+
content=message,
|
|
12
|
+
meta={"type": "operation_succeeded", **meta},
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configuration_created(path: ProjectConfigPath) -> ContentCell:
|
|
17
|
+
return operation_succeeded("Configuration created.", path=str(path))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def operation_failed(message: str, **meta: MetaValue) -> ContentCell:
|
|
21
|
+
return ContentCell(kind="operation_failed", media_type="text/markdown", content=message, meta=meta)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def info(message: str, **meta: MetaValue) -> ContentCell:
|
|
25
|
+
return ContentCell(kind="info", media_type="text/markdown", content=message, meta=meta)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def skill(document: str, content: str) -> ContentCell:
|
|
29
|
+
return ContentCell(
|
|
30
|
+
kind="skill", media_type="text/markdown", content=content, meta={"type": "skill", "document": document}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version(value: str) -> ContentCell:
|
|
35
|
+
return ContentCell(kind="version", meta={"type": "version", "version": value})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def environment_error(error: EnvironmentError) -> EnvironmentErrorCell:
|
|
39
|
+
return EnvironmentErrorCell(error=error)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Internal failures in output protocol construction and formatting."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.core import errors as core_errors
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InternalError(core_errors.InternalError):
|
|
9
|
+
"""Base for internal output protocol failures."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ContentWithoutMediaType(InternalError):
|
|
13
|
+
message_template: ClassVar[str] = "Cannot set content when media_type is None."
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedFormatterMode(InternalError):
|
|
17
|
+
message_template: ClassVar[str] = "Formatter for mode '{mode}' is not implemented."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JournalConversionUnsupported(InternalError):
|
|
21
|
+
message_template: ClassVar[str] = "Cell '{cell_type}' does not support journal conversion."
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JournalRecord(BaseEntity):
|
|
9
|
+
"""Protocol-independent journal data, ready for an external processor."""
|
|
10
|
+
|
|
11
|
+
message: str
|
|
12
|
+
timestamp: datetime.datetime = pydantic.Field(default_factory=lambda: datetime.datetime.now(datetime.UTC))
|
|
13
|
+
actor_id: str | None = None
|
|
14
|
+
attributes: dict[str, pydantic.JsonValue] = pydantic.Field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def as_dict(self) -> dict[str, pydantic.JsonValue]:
|
|
17
|
+
"""Flatten attributes without allowing them to replace common fields."""
|
|
18
|
+
fields = self.model_dump(mode="json")
|
|
19
|
+
attributes = fields.pop("attributes")
|
|
20
|
+
fields["timestamp"] = self.timestamp.isoformat()
|
|
21
|
+
return {**attributes, **fields}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import assert_never
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
7
|
+
from llm_tool_cli.protocol.entities import Protocol
|
|
8
|
+
from llm_tool_cli.protocol.errors import JournalConversionUnsupported
|
|
9
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
10
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LogicCell(BaseEntity, ABC):
|
|
14
|
+
"""Application data projected into prepared output cells for a protocol."""
|
|
15
|
+
|
|
16
|
+
is_journal: bool = pydantic.Field(default=False, exclude=True)
|
|
17
|
+
is_warning: bool = pydantic.Field(default=False, exclude=True)
|
|
18
|
+
|
|
19
|
+
def to_journal_record(self) -> JournalRecord:
|
|
20
|
+
"""Journal-capable cells must describe their event explicitly."""
|
|
21
|
+
raise JournalConversionUnsupported(details={"cell_type": type(self).__name__})
|
|
22
|
+
|
|
23
|
+
def render(self, protocol: Protocol) -> list[OutputCell]:
|
|
24
|
+
match protocol:
|
|
25
|
+
case Protocol.human:
|
|
26
|
+
return self.render_human()
|
|
27
|
+
case Protocol.llm:
|
|
28
|
+
return self.render_llm()
|
|
29
|
+
case Protocol.automation:
|
|
30
|
+
return self.render_automation()
|
|
31
|
+
case _:
|
|
32
|
+
assert_never(protocol)
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def render_human(self) -> list[OutputCell]: ... # noqa: E704
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def render_llm(self) -> list[OutputCell]: ... # noqa: E704
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def render_automation(self) -> list[OutputCell]: ... # noqa: E704
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Literal, Self
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.protocol.errors import ContentWithoutMediaType
|
|
6
|
+
from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
|
|
7
|
+
from llm_tool_cli.protocol.output_cells.base import MetaValue, OutputCell
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContentCell(UniformCell):
|
|
11
|
+
"""Prepared content and metadata shared by all output protocols."""
|
|
12
|
+
|
|
13
|
+
is_journal: Literal[False] = pydantic.Field(default=False, exclude=True)
|
|
14
|
+
kind: str
|
|
15
|
+
media_type: str | None = None
|
|
16
|
+
content: str | None = None
|
|
17
|
+
meta: dict[str, MetaValue] = pydantic.Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
@pydantic.model_validator(mode="after")
|
|
20
|
+
def validate_content(self) -> Self:
|
|
21
|
+
if self.media_type is None and self.content is not None:
|
|
22
|
+
raise ContentWithoutMediaType()
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def _render(self, cell_type: type[OutputCell]) -> OutputCell:
|
|
26
|
+
return cell_type(kind=self.kind, media_type=self.media_type, content=self.content, meta=self.meta)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from llm_tool_cli.core.errors import EnvironmentError
|
|
2
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
3
|
+
from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
|
|
4
|
+
from llm_tool_cli.protocol.output_cells import HumanErrorOutputCell
|
|
5
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell, to_meta_value
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EnvironmentErrorCell(UniformCell):
|
|
9
|
+
"""An operational failure retained as typed data until projection."""
|
|
10
|
+
|
|
11
|
+
error: EnvironmentError
|
|
12
|
+
|
|
13
|
+
def render_human(self) -> list[OutputCell]:
|
|
14
|
+
return [self._render(HumanErrorOutputCell)]
|
|
15
|
+
|
|
16
|
+
def to_journal_record(self) -> JournalRecord:
|
|
17
|
+
fields = self.error.as_record()
|
|
18
|
+
message = str(fields.pop("message"))
|
|
19
|
+
# Native error records already contain JSON-compatible serialized context.
|
|
20
|
+
return JournalRecord(message=message, attributes=fields) # type: ignore[arg-type]
|
|
21
|
+
|
|
22
|
+
def _render(self, cell_type: type[OutputCell]) -> OutputCell:
|
|
23
|
+
record = self.error.as_record()
|
|
24
|
+
content = str(record.pop("message"))
|
|
25
|
+
fixes = [fix.format(error=self.error).strip() for fix in self.error.ways_to_fix]
|
|
26
|
+
if len(fixes) == 1:
|
|
27
|
+
content = f"{content}\nWay to fix: {fixes[0]}"
|
|
28
|
+
elif fixes:
|
|
29
|
+
guidance = "\n".join(f"- {fix}" for fix in fixes)
|
|
30
|
+
content = f"{content}\n\nWays to fix:\n\n{guidance}"
|
|
31
|
+
return cell_type(
|
|
32
|
+
kind="error",
|
|
33
|
+
media_type="text/markdown",
|
|
34
|
+
content=content,
|
|
35
|
+
meta={key: to_meta_value(value) for key, value in record.items()},
|
|
36
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
6
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
7
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell, to_meta_value
|
|
8
|
+
from llm_tool_cli.protocol.output_cells.journal import JournalAutomationOutputCell, JournalTextOutputCell
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JournalCell(LogicCell):
|
|
12
|
+
"""A journal event with compact text and structured automation projections."""
|
|
13
|
+
|
|
14
|
+
is_journal: bool = pydantic.Field(default=True, exclude=True)
|
|
15
|
+
timestamp: datetime.datetime = pydantic.Field(default_factory=lambda: datetime.datetime.now(datetime.UTC))
|
|
16
|
+
actor_id: str | None = None
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
def to_journal_record(self) -> JournalRecord:
|
|
20
|
+
return JournalRecord(
|
|
21
|
+
message=self.message,
|
|
22
|
+
timestamp=self.timestamp,
|
|
23
|
+
actor_id=self.actor_id,
|
|
24
|
+
attributes=self.model_dump(mode="json", exclude={"message", "timestamp", "actor_id"}),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def human_line(self) -> str:
|
|
28
|
+
return f"{self.timestamp.time().isoformat('seconds')} <{self.actor_id or '-'}> {self.message}"
|
|
29
|
+
|
|
30
|
+
def llm_line(self) -> str:
|
|
31
|
+
return f"{self.timestamp.isoformat()} <{self.actor_id or '-'}> {self.message}"
|
|
32
|
+
|
|
33
|
+
def render_human(self) -> list[OutputCell]:
|
|
34
|
+
return [JournalTextOutputCell.build(kind="journal", media_type="text/plain", content=self.human_line())]
|
|
35
|
+
|
|
36
|
+
def render_llm(self) -> list[OutputCell]:
|
|
37
|
+
return [JournalTextOutputCell.build(kind="journal", media_type="text/plain", content=self.llm_line())]
|
|
38
|
+
|
|
39
|
+
def render_automation(self) -> list[OutputCell]:
|
|
40
|
+
return [
|
|
41
|
+
JournalAutomationOutputCell.build_meta(
|
|
42
|
+
kind="journal", **{key: to_meta_value(value) for key, value in self.model_dump(mode="json").items()}
|
|
43
|
+
)
|
|
44
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol import Protocol
|
|
4
|
+
from llm_tool_cli.protocol.errors import JournalConversionUnsupported
|
|
5
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
6
|
+
from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell
|
|
7
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExampleLogicCell(LogicCell):
|
|
11
|
+
items: tuple[str, ...]
|
|
12
|
+
|
|
13
|
+
def render_human(self) -> list[OutputCell]:
|
|
14
|
+
return [HumanOutputCell.build_markdown(kind="item", content=item) for item in self.items]
|
|
15
|
+
|
|
16
|
+
def render_llm(self) -> list[OutputCell]:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
def render_automation(self) -> list[OutputCell]:
|
|
20
|
+
return [AutomationOutputCell.build_meta(kind="items", items=list(self.items))]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestLogicCell:
|
|
24
|
+
def test_to_journal_record__unsupported_conversion_is_explicit(self) -> None:
|
|
25
|
+
cell = ExampleLogicCell(items=("first", "second"))
|
|
26
|
+
|
|
27
|
+
with pytest.raises(JournalConversionUnsupported) as caught:
|
|
28
|
+
cell.to_journal_record()
|
|
29
|
+
|
|
30
|
+
assert caught.value.details == {"cell_type": "ExampleLogicCell"}
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize(
|
|
33
|
+
("protocol", "expected"),
|
|
34
|
+
(
|
|
35
|
+
(Protocol.human, [("item", "first"), ("item", "second")]),
|
|
36
|
+
(Protocol.llm, []),
|
|
37
|
+
(Protocol.automation, [("items", None)]),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
def test_render__dispatches_to_protocol_projection(
|
|
41
|
+
self, protocol: Protocol, expected: list[tuple[str, str | None]]
|
|
42
|
+
) -> None:
|
|
43
|
+
cell = ExampleLogicCell(items=("first", "second"))
|
|
44
|
+
|
|
45
|
+
result = cell.render(protocol)
|
|
46
|
+
|
|
47
|
+
assert [(output.kind, output.content) for output in result] == expected
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pydantic
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from llm_tool_cli.protocol import Protocol
|
|
5
|
+
from llm_tool_cli.protocol.errors import ContentWithoutMediaType
|
|
6
|
+
from llm_tool_cli.protocol.logic_cells import ContentCell
|
|
7
|
+
from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell, LLMOutputCell
|
|
8
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestContentCell:
|
|
12
|
+
def test_validation__prohibits_journaling_generic_content(self) -> None:
|
|
13
|
+
with pytest.raises(pydantic.ValidationError):
|
|
14
|
+
ContentCell.model_validate({"kind": "document", "is_journal": True})
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize(
|
|
17
|
+
("protocol", "cell_type"),
|
|
18
|
+
[
|
|
19
|
+
(Protocol.human, HumanOutputCell),
|
|
20
|
+
(Protocol.llm, LLMOutputCell),
|
|
21
|
+
(Protocol.automation, AutomationOutputCell),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
@pytest.mark.parametrize(("media_type", "content"), [("text/markdown", "café"), (None, None), ("text/plain", "")])
|
|
25
|
+
def test_render__preserves_payload_and_creates_fresh_output(
|
|
26
|
+
self, protocol: Protocol, cell_type: type[OutputCell], media_type: str | None, content: str | None
|
|
27
|
+
) -> None:
|
|
28
|
+
cell = ContentCell(
|
|
29
|
+
kind="item",
|
|
30
|
+
media_type=media_type,
|
|
31
|
+
content=content,
|
|
32
|
+
meta={"labels": ["one", "two"], "enabled": True, "empty": None},
|
|
33
|
+
)
|
|
34
|
+
original = cell.model_dump()
|
|
35
|
+
|
|
36
|
+
first = cell.render(protocol)
|
|
37
|
+
second = cell.render(protocol)
|
|
38
|
+
|
|
39
|
+
assert len(first) == len(second) == 1
|
|
40
|
+
assert isinstance(first[0], cell_type)
|
|
41
|
+
assert isinstance(second[0], cell_type)
|
|
42
|
+
assert first[0].model_dump(exclude={"id"}) == second[0].model_dump(exclude={"id"}) == original
|
|
43
|
+
assert first[0].id.version == second[0].id.version == 4
|
|
44
|
+
assert first[0].id != second[0].id
|
|
45
|
+
assert cell.model_dump() == original
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize("content", ["text", ""])
|
|
48
|
+
def test_validate_content__rejects_missing_media_type(self, content: str) -> None:
|
|
49
|
+
with pytest.raises(ContentWithoutMediaType):
|
|
50
|
+
ContentCell(kind="item", content=content)
|
|
51
|
+
|
|
52
|
+
def test_render__preserves_reserved_metadata_fields(self) -> None:
|
|
53
|
+
cell = ContentCell(kind="item", meta={"id": "custom", "content": "metadata", "kind": "metadata_kind"})
|
|
54
|
+
|
|
55
|
+
output = cell.render(Protocol.automation)[0]
|
|
56
|
+
|
|
57
|
+
assert output.kind == "item"
|
|
58
|
+
assert output.content is None
|
|
59
|
+
assert output.meta == cell.meta
|