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,257 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest_mock import MockerFixture
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.config import errors
|
|
9
|
+
from llm_tool_cli.config.project import (
|
|
10
|
+
ProjectConfig,
|
|
11
|
+
_load_project_config,
|
|
12
|
+
initialize_project,
|
|
13
|
+
load_project,
|
|
14
|
+
project_config,
|
|
15
|
+
)
|
|
16
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
17
|
+
from llm_tool_cli.config.tests.fixtures import project_static_settings
|
|
18
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
19
|
+
from llm_tool_cli.core.errors import EnvironmentErrors
|
|
20
|
+
from llm_tool_cli.core.result import Err, Ok, UnwrapError
|
|
21
|
+
from llm_tool_cli.core.settings import ConfigFilename, StaticConfig, ToolLabel, static_config
|
|
22
|
+
from llm_tool_cli.paths import PathInput, ProjectConfigPath
|
|
23
|
+
from llm_tool_cli.paths.errors import InvalidProjectPath
|
|
24
|
+
from llm_tool_cli.protocol import Protocol
|
|
25
|
+
|
|
26
|
+
__all__ = ["project_static_settings"]
|
|
27
|
+
|
|
28
|
+
pytestmark = pytest.mark.usefixtures("project_static_settings")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApplicationConfig(BaseEntity):
|
|
32
|
+
version: Literal[1] = 1
|
|
33
|
+
count: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestProjectConfig:
|
|
37
|
+
def test_project_dir__derives_from_configuration_path(self) -> None:
|
|
38
|
+
config = ProjectConfig(config_path=ProjectConfigPath(Path("/project/custom.toml")))
|
|
39
|
+
|
|
40
|
+
assert config.project_dir == Path("/project")
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("path", ["~/config.toml", "relative.toml"])
|
|
43
|
+
def test_validate_config_path__rejects_unresolved_inputs(self, path: str) -> None:
|
|
44
|
+
with pytest.raises(pydantic.ValidationError):
|
|
45
|
+
ProjectConfig(config_path=ProjectConfigPath(Path(path)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestLoadProject:
|
|
49
|
+
def test_discovers_nearest_configuration_and_restores_scope(self, tmp_path: Path) -> None:
|
|
50
|
+
(tmp_path / "tool.toml").write_text("count = 1", encoding="utf-8")
|
|
51
|
+
project = tmp_path / "project"
|
|
52
|
+
nested = project / "nested"
|
|
53
|
+
nested.mkdir(parents=True)
|
|
54
|
+
path = project / "tool.toml"
|
|
55
|
+
path.write_text("count = 2", encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
scope = load_project(ApplicationConfig, cwd=PathInput(nested))
|
|
58
|
+
assert not project_config.is_set()
|
|
59
|
+
with scope as result:
|
|
60
|
+
assert result.unwrap().count == 2
|
|
61
|
+
assert project_config.get().config_path == path
|
|
62
|
+
assert project_config.get().project_dir == project
|
|
63
|
+
assert not runtime_config.is_set()
|
|
64
|
+
assert not project_config.is_set()
|
|
65
|
+
|
|
66
|
+
def test_uses_static_filename(self, tmp_path: Path) -> None:
|
|
67
|
+
(tmp_path / "custom.toml").write_text("count = 3", encoding="utf-8")
|
|
68
|
+
settings = StaticConfig(tool_label=ToolLabel("TEST"), config_filename=ConfigFilename("custom.toml"))
|
|
69
|
+
with static_config.scope(settings), load_project(ApplicationConfig, cwd=PathInput(tmp_path)) as result:
|
|
70
|
+
assert result.unwrap().count == 3
|
|
71
|
+
assert project_config.get().config_path == tmp_path / "custom.toml"
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
74
|
+
def test_rejects_config_symlink_without_binding_project(self, tmp_path: Path, explicit: bool) -> None:
|
|
75
|
+
target_dir = tmp_path / "target"
|
|
76
|
+
target_dir.mkdir()
|
|
77
|
+
target = target_dir / "actual.toml"
|
|
78
|
+
target.write_text("count = 4", encoding="utf-8")
|
|
79
|
+
link = tmp_path / "tool.toml"
|
|
80
|
+
link.symlink_to(target)
|
|
81
|
+
path = ProjectConfigPath(Path("tool.toml")) if explicit else None
|
|
82
|
+
|
|
83
|
+
with load_project(ApplicationConfig, cwd=PathInput(tmp_path), path=path) as result:
|
|
84
|
+
assert result.unwrap_err() == [InvalidProjectPath(path=str(link))]
|
|
85
|
+
assert not project_config.is_set()
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
88
|
+
def test_directory_alias_selects_resolved_project(self, tmp_path: Path, explicit: bool) -> None:
|
|
89
|
+
root = tmp_path / "project"
|
|
90
|
+
root.mkdir()
|
|
91
|
+
(root / "tool.toml").write_text("count = 4", encoding="utf-8")
|
|
92
|
+
alias = tmp_path / "alias"
|
|
93
|
+
alias.symlink_to(root, target_is_directory=True)
|
|
94
|
+
|
|
95
|
+
with load_project(
|
|
96
|
+
ApplicationConfig,
|
|
97
|
+
cwd=PathInput(alias),
|
|
98
|
+
path=ProjectConfigPath(alias / "tool.toml") if explicit else None,
|
|
99
|
+
) as result:
|
|
100
|
+
assert result.unwrap().count == 4
|
|
101
|
+
assert project_config.get().config_path == root / "tool.toml"
|
|
102
|
+
assert project_config.get().project_dir == root
|
|
103
|
+
|
|
104
|
+
def test_missing_explicit_path_does_not_fall_back(self, tmp_path: Path) -> None:
|
|
105
|
+
(tmp_path / "tool.toml").write_text("", encoding="utf-8")
|
|
106
|
+
with load_project(
|
|
107
|
+
ApplicationConfig, cwd=PathInput(tmp_path), path=ProjectConfigPath(Path("missing.toml"))
|
|
108
|
+
) as result:
|
|
109
|
+
error = result.unwrap_err()[0]
|
|
110
|
+
assert isinstance(error, errors.Unreadable)
|
|
111
|
+
assert error.path == tmp_path / "missing.toml"
|
|
112
|
+
assert not project_config.is_set()
|
|
113
|
+
|
|
114
|
+
def test_missing_discovered_configuration(self, mocker: MockerFixture, tmp_path: Path) -> None:
|
|
115
|
+
mocker.patch("llm_tool_cli.config.files.find_config", return_value=Ok(None))
|
|
116
|
+
with load_project(ApplicationConfig, cwd=PathInput(tmp_path)) as result:
|
|
117
|
+
error = result.unwrap_err()[0]
|
|
118
|
+
assert isinstance(error, errors.NotFound)
|
|
119
|
+
assert error.path == tmp_path
|
|
120
|
+
assert "tool.toml" in error.reason
|
|
121
|
+
assert not project_config.is_set()
|
|
122
|
+
|
|
123
|
+
def test_missing_static_filename_is_internal_misuse(self, tmp_path: Path) -> None:
|
|
124
|
+
with static_config.scope(StaticConfig(tool_label=ToolLabel("TEST"))):
|
|
125
|
+
with pytest.raises(errors.ConfigFilenameNotSet):
|
|
126
|
+
with load_project(ApplicationConfig, cwd=PathInput(tmp_path)):
|
|
127
|
+
pytest.fail("Project setup must reject missing application metadata.")
|
|
128
|
+
assert not project_config.is_set()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestInitializeProject:
|
|
132
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
133
|
+
def test_creates_validates_and_scopes_without_discovery(
|
|
134
|
+
self, mocker: MockerFixture, tmp_path: Path, explicit: bool
|
|
135
|
+
) -> None:
|
|
136
|
+
package = tmp_path / "package"
|
|
137
|
+
(package / "fixtures").mkdir(parents=True)
|
|
138
|
+
text = "version = 1\ncount = 5\n"
|
|
139
|
+
(package / "fixtures/starter.toml").write_text(text, encoding="utf-8")
|
|
140
|
+
mocker.patch("llm_tool_cli.config.files.importlib.resources.files", return_value=package)
|
|
141
|
+
(tmp_path / "tool.toml").write_text("count = 9", encoding="utf-8")
|
|
142
|
+
child = tmp_path / "child"
|
|
143
|
+
child.mkdir()
|
|
144
|
+
path = ProjectConfigPath(Path("custom.toml")) if explicit else None
|
|
145
|
+
created = child / ("custom.toml" if explicit else "tool.toml")
|
|
146
|
+
|
|
147
|
+
scope = initialize_project(
|
|
148
|
+
ApplicationConfig, package="test", template="starter.toml", cwd=PathInput(child), path=path
|
|
149
|
+
)
|
|
150
|
+
assert not created.exists()
|
|
151
|
+
with scope as result:
|
|
152
|
+
assert result.unwrap().count == 5
|
|
153
|
+
assert created.read_text(encoding="utf-8") == text
|
|
154
|
+
assert project_config.get().config_path == created
|
|
155
|
+
assert project_config.get().project_dir == child
|
|
156
|
+
assert not project_config.is_set()
|
|
157
|
+
|
|
158
|
+
@pytest.mark.parametrize(
|
|
159
|
+
"text, error_type", [("version =", errors.InvalidToml), ("version = 2", errors.ValidationFailed)]
|
|
160
|
+
)
|
|
161
|
+
def test_invalid_starter_remains_on_disk_without_binding(
|
|
162
|
+
self, mocker: MockerFixture, tmp_path: Path, text: str, error_type: type[errors.EnvironmentError]
|
|
163
|
+
) -> None:
|
|
164
|
+
package = tmp_path / "package"
|
|
165
|
+
(package / "fixtures").mkdir(parents=True)
|
|
166
|
+
(package / "fixtures/starter.toml").write_text(text, encoding="utf-8")
|
|
167
|
+
mocker.patch("llm_tool_cli.config.files.importlib.resources.files", return_value=package)
|
|
168
|
+
with initialize_project(
|
|
169
|
+
ApplicationConfig, package="test", template="starter.toml", cwd=PathInput(tmp_path)
|
|
170
|
+
) as result:
|
|
171
|
+
assert isinstance(result.unwrap_err()[0], error_type)
|
|
172
|
+
assert (tmp_path / "tool.toml").read_text(encoding="utf-8") == text
|
|
173
|
+
assert not project_config.is_set()
|
|
174
|
+
|
|
175
|
+
def test_creation_failure_skips_loading_and_preserves_binding(self, mocker: MockerFixture, tmp_path: Path) -> None:
|
|
176
|
+
failure = errors.Unwritable(path=tmp_path / "tool.toml", reason="denied")
|
|
177
|
+
mocker.patch("llm_tool_cli.config.project.initialize_config", return_value=Err([failure]))
|
|
178
|
+
loading = mocker.patch("llm_tool_cli.config.project.load_config")
|
|
179
|
+
previous = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "active.toml"))
|
|
180
|
+
with project_config.scope(previous):
|
|
181
|
+
with initialize_project(
|
|
182
|
+
ApplicationConfig, package="test", template="starter.toml", cwd=PathInput(tmp_path)
|
|
183
|
+
) as result:
|
|
184
|
+
assert result.unwrap_err() == [failure]
|
|
185
|
+
assert project_config.get() == previous
|
|
186
|
+
assert project_config.get() == previous
|
|
187
|
+
loading.assert_not_called()
|
|
188
|
+
|
|
189
|
+
def test_missing_static_filename_is_internal_misuse(self, tmp_path: Path) -> None:
|
|
190
|
+
with static_config.scope(StaticConfig(tool_label=ToolLabel("TEST"))):
|
|
191
|
+
with pytest.raises(errors.ConfigFilenameNotSet):
|
|
192
|
+
with initialize_project(
|
|
193
|
+
ApplicationConfig, package="test", template="starter.toml", cwd=PathInput(tmp_path)
|
|
194
|
+
):
|
|
195
|
+
pytest.fail("Project setup must reject missing application metadata.")
|
|
196
|
+
assert not list(tmp_path.iterdir())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TestLoadProjectConfig:
|
|
200
|
+
@pytest.mark.parametrize("failure", [None, RuntimeError("body failed"), UnwrapError(error=[])])
|
|
201
|
+
def test_restores_previous_binding_after_success_and_body_failures(
|
|
202
|
+
self, tmp_path: Path, failure: Exception | None
|
|
203
|
+
) -> None:
|
|
204
|
+
path = ProjectConfigPath(tmp_path / "tool.toml")
|
|
205
|
+
path.write_text("count = 7", encoding="utf-8")
|
|
206
|
+
previous = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "outer.toml"))
|
|
207
|
+
with project_config.scope(previous), runtime_config.scope(RuntimeConfig(protocol=Protocol.llm)):
|
|
208
|
+
try:
|
|
209
|
+
with _load_project_config(Ok(path), ApplicationConfig) as result:
|
|
210
|
+
assert result.unwrap().count == 7
|
|
211
|
+
assert project_config.get().config_path == path
|
|
212
|
+
assert runtime_config.get().protocol == Protocol.llm
|
|
213
|
+
if failure is not None:
|
|
214
|
+
raise failure
|
|
215
|
+
except Exception as caught:
|
|
216
|
+
assert caught == failure
|
|
217
|
+
else:
|
|
218
|
+
assert failure is None
|
|
219
|
+
assert project_config.get() == previous
|
|
220
|
+
|
|
221
|
+
def test_selection_failure_is_preserved_without_loading(self, mocker: MockerFixture, tmp_path: Path) -> None:
|
|
222
|
+
failures: EnvironmentErrors = [
|
|
223
|
+
errors.DiscoveryFailed(path=tmp_path, reason="denied"),
|
|
224
|
+
errors.NotFound(path=tmp_path, reason="missing"),
|
|
225
|
+
]
|
|
226
|
+
loading = mocker.patch("llm_tool_cli.config.project.load_config")
|
|
227
|
+
with _load_project_config(Err(failures), ApplicationConfig) as result:
|
|
228
|
+
assert result.unwrap_err() == failures
|
|
229
|
+
assert not project_config.is_set()
|
|
230
|
+
loading.assert_not_called()
|
|
231
|
+
|
|
232
|
+
@pytest.mark.parametrize(
|
|
233
|
+
"content, error_type",
|
|
234
|
+
[
|
|
235
|
+
(b"count =", errors.InvalidToml),
|
|
236
|
+
(b"\xff", errors.InvalidEncoding),
|
|
237
|
+
(b"version = 2", errors.ValidationFailed),
|
|
238
|
+
],
|
|
239
|
+
)
|
|
240
|
+
def test_loading_failure_preserves_previous_project(
|
|
241
|
+
self, tmp_path: Path, content: bytes, error_type: type[errors.EnvironmentError]
|
|
242
|
+
) -> None:
|
|
243
|
+
path = ProjectConfigPath(tmp_path / "tool.toml")
|
|
244
|
+
path.write_bytes(content)
|
|
245
|
+
previous = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "active.toml"))
|
|
246
|
+
with project_config.scope(previous), _load_project_config(Ok(path), ApplicationConfig) as result:
|
|
247
|
+
assert isinstance(result.unwrap_err()[0], error_type)
|
|
248
|
+
assert project_config.get() == previous
|
|
249
|
+
|
|
250
|
+
def test_unexpected_loading_failure_propagates(self, mocker: MockerFixture, tmp_path: Path) -> None:
|
|
251
|
+
failure = RuntimeError("unexpected schema defect")
|
|
252
|
+
mocker.patch("llm_tool_cli.config.project.load_config", side_effect=failure)
|
|
253
|
+
with pytest.raises(RuntimeError) as caught:
|
|
254
|
+
with _load_project_config(Ok(ProjectConfigPath(tmp_path / "tool.toml")), ApplicationConfig):
|
|
255
|
+
pytest.fail("Loading must fail before entering the project.")
|
|
256
|
+
assert caught.value == failure
|
|
257
|
+
assert not project_config.is_set()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared infrastructure; ``core.errors`` defines the public exception base."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Independent typed bindings with initialization and temporary scopes."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.core.errors import ContextValueAlreadySet, ContextValueNotSet
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContextValue[T]:
|
|
11
|
+
"""Access one value in the current execution context.
|
|
12
|
+
|
|
13
|
+
The name is diagnostic only. Each instance owns an independent binding;
|
|
14
|
+
binding an object does not copy it or manage its resources.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_variable",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, name: str) -> None:
|
|
20
|
+
self._variable: ContextVar[T] = ContextVar(name)
|
|
21
|
+
|
|
22
|
+
def get(self) -> T:
|
|
23
|
+
"""Return the bound value, raising an internal exception when unset."""
|
|
24
|
+
try:
|
|
25
|
+
return self._variable.get()
|
|
26
|
+
except LookupError as error:
|
|
27
|
+
raise ContextValueNotSet(details={"name": self._variable.name}) from error
|
|
28
|
+
|
|
29
|
+
def is_set(self) -> bool:
|
|
30
|
+
"""Report whether a value is bound, including an explicit None."""
|
|
31
|
+
try:
|
|
32
|
+
self._variable.get()
|
|
33
|
+
except LookupError:
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def initialize(self, value: T) -> None:
|
|
38
|
+
"""Install a value once in the current context, rejecting reassignment."""
|
|
39
|
+
if self.is_set():
|
|
40
|
+
raise ContextValueAlreadySet(details={"name": self._variable.name})
|
|
41
|
+
self._variable.set(value)
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def scope(self, value: T) -> Iterator[None]:
|
|
45
|
+
"""Temporarily bind a value and restore the previous binding on exit."""
|
|
46
|
+
token = self._variable.set(value)
|
|
47
|
+
try:
|
|
48
|
+
yield
|
|
49
|
+
finally:
|
|
50
|
+
self._variable.reset(token)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExitCode(IntEnum):
|
|
8
|
+
success = 0
|
|
9
|
+
invalid_arguments = 1
|
|
10
|
+
configuration_error = 2
|
|
11
|
+
environment_error = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseEntity(pydantic.BaseModel):
|
|
15
|
+
"""Shared validation, copying, and JSON serialization for structured values."""
|
|
16
|
+
|
|
17
|
+
model_config = pydantic.ConfigDict(
|
|
18
|
+
str_strip_whitespace=True,
|
|
19
|
+
validate_default=True,
|
|
20
|
+
extra="forbid",
|
|
21
|
+
frozen=True,
|
|
22
|
+
validate_assignment=True,
|
|
23
|
+
from_attributes=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def replace(self, **changes: object) -> Self:
|
|
27
|
+
"""Return a deep copy with trusted changes applied without validation."""
|
|
28
|
+
return self.model_copy(update=changes, deep=True)
|
|
29
|
+
|
|
30
|
+
def to_json(self) -> str:
|
|
31
|
+
return self.model_dump_json(indent=2)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_json(cls, json_data: str) -> Self:
|
|
35
|
+
return cls.model_validate_json(json_data)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import ClassVar, Self
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.core.entities import BaseEntity, ExitCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InternalError(Exception):
|
|
10
|
+
"""Base for internal and technical exceptions with structured context.
|
|
11
|
+
|
|
12
|
+
Details are copied shallowly and may contain arbitrary debugging context or
|
|
13
|
+
technical propagation payloads. They are not environment-error diagnostics.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
message_template: ClassVar[str | None] = None
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str | None = None,
|
|
21
|
+
*,
|
|
22
|
+
details: Mapping[str, object] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.details: dict[str, object] = dict(details) if details is not None else {}
|
|
25
|
+
if message is None and self.message_template is not None:
|
|
26
|
+
message = self.message_template.format(**self.details)
|
|
27
|
+
super().__init__(type(self).__name__ if message is None else message)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def message(self) -> str:
|
|
31
|
+
return super().__str__()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContextValueNotSet(InternalError):
|
|
35
|
+
message_template = "Context value {name!r} is not set."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContextValueAlreadySet(InternalError):
|
|
39
|
+
message_template = "Context value {name!r} is already set."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ToolLabelAlreadyInitialized(InternalError):
|
|
43
|
+
message_template = "Tool label is already initialized as {current!r}; cannot initialize it as {requested!r}."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EnvironmentError(BaseEntity):
|
|
47
|
+
"""An expected operational failure returned as data.
|
|
48
|
+
|
|
49
|
+
Subclasses declare typed context fields. Messages and corrective guidance may
|
|
50
|
+
reference those fields using ``{error.field}``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
cli_exit_code: ClassVar[ExitCode] = ExitCode.environment_error
|
|
54
|
+
code: str
|
|
55
|
+
message: str
|
|
56
|
+
ways_to_fix: list[str] = pydantic.Field(default_factory=list)
|
|
57
|
+
_cause: Exception | None = pydantic.PrivateAttr(default=None)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def cause(self) -> Exception | None:
|
|
61
|
+
return self._cause
|
|
62
|
+
|
|
63
|
+
def with_cause(self, cause: Exception) -> Self:
|
|
64
|
+
"""Attach the original failure at its translation boundary."""
|
|
65
|
+
self._cause = cause
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def format_message(self) -> str:
|
|
69
|
+
return self.message.format(error=self)
|
|
70
|
+
|
|
71
|
+
def as_record(self) -> dict[str, object]:
|
|
72
|
+
"""Serialize typed context with the stable diagnostic record fields."""
|
|
73
|
+
context = self.model_dump(mode="json", exclude={"code", "message", "ways_to_fix"})
|
|
74
|
+
return {
|
|
75
|
+
**context,
|
|
76
|
+
"type": "error",
|
|
77
|
+
"code": self.code,
|
|
78
|
+
"message": self.format_message(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
EnvironmentErrors = list[EnvironmentError]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def exit_code_for_errors(errors: EnvironmentErrors) -> ExitCode:
|
|
86
|
+
"""Return the highest declared exit code, or success for an empty list."""
|
|
87
|
+
return max((error.cli_exit_code for error in errors), default=ExitCode.success)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class EnvironmentErrorsProxy(InternalError):
|
|
91
|
+
"""Carry environment errors through callbacks that cannot return results."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, errors: EnvironmentErrors) -> None:
|
|
94
|
+
super().__init__(
|
|
95
|
+
"This is a technical exception to pass an environment error up the call stack.",
|
|
96
|
+
details={"errors": errors},
|
|
97
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Callable, Generic, Never, ParamSpec, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.core.errors import EnvironmentError, EnvironmentErrors, InternalError
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T", covariant=True)
|
|
9
|
+
U = TypeVar("U")
|
|
10
|
+
TValue = TypeVar("TValue")
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnwrapError(InternalError):
|
|
15
|
+
def __init__(self, error: EnvironmentErrors) -> None:
|
|
16
|
+
super().__init__("Called unwrap on an Err value.", details={"error": error})
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def errors(self) -> EnvironmentErrors:
|
|
20
|
+
"""Return the original error list, or re-raise this exception if malformed.
|
|
21
|
+
|
|
22
|
+
The payload lives only in ``details["error"]``. Lists, including empty
|
|
23
|
+
lists, are returned unchanged after verifying every item is an
|
|
24
|
+
environment error. Other payloads are internal failures, not recoverable
|
|
25
|
+
diagnostics.
|
|
26
|
+
"""
|
|
27
|
+
errors = self.details.get("error")
|
|
28
|
+
if isinstance(errors, list) and all(isinstance(error, EnvironmentError) for error in errors):
|
|
29
|
+
return errors
|
|
30
|
+
raise self
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnwrapErrError(InternalError):
|
|
34
|
+
def __init__(self, value: object) -> None:
|
|
35
|
+
super().__init__("Called unwrap_err on an Ok value.", details={"value": value})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Result(Generic[T]):
|
|
39
|
+
__slots__ = ("_is_ok", "_value")
|
|
40
|
+
|
|
41
|
+
def __init__(self, is_ok: bool, value: T | EnvironmentErrors) -> None:
|
|
42
|
+
self._is_ok = is_ok
|
|
43
|
+
self._value = value
|
|
44
|
+
|
|
45
|
+
def is_ok(self) -> bool:
|
|
46
|
+
return self._is_ok
|
|
47
|
+
|
|
48
|
+
def is_err(self, error_type: type[EnvironmentError] | None = None) -> bool:
|
|
49
|
+
"""Check for failure, optionally requiring all environment errors to match.
|
|
50
|
+
|
|
51
|
+
A requested type also matches subclasses. Successes, empty lists, and
|
|
52
|
+
errors outside that type do not match. Without a type, every failed
|
|
53
|
+
result matches, including one with an empty error list.
|
|
54
|
+
"""
|
|
55
|
+
if self._is_ok:
|
|
56
|
+
return False
|
|
57
|
+
if error_type is None:
|
|
58
|
+
return True
|
|
59
|
+
errors = cast(EnvironmentErrors, self._value)
|
|
60
|
+
return bool(errors) and all(isinstance(error, error_type) for error in errors)
|
|
61
|
+
|
|
62
|
+
def ok(self) -> T | None:
|
|
63
|
+
if self._is_ok:
|
|
64
|
+
return cast(T, self._value)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def err(self) -> EnvironmentErrors | None:
|
|
68
|
+
if not self._is_ok:
|
|
69
|
+
return cast(EnvironmentErrors, self._value)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def unwrap(self) -> T:
|
|
73
|
+
if self._is_ok:
|
|
74
|
+
return cast(T, self._value)
|
|
75
|
+
|
|
76
|
+
raise UnwrapError(error=self.unwrap_err())
|
|
77
|
+
|
|
78
|
+
def unwrap_err(self) -> EnvironmentErrors:
|
|
79
|
+
if not self._is_ok:
|
|
80
|
+
return cast(EnvironmentErrors, self._value)
|
|
81
|
+
|
|
82
|
+
raise UnwrapErrError(value=self.unwrap())
|
|
83
|
+
|
|
84
|
+
def unwrap_or(self, default: U) -> T | U:
|
|
85
|
+
if self._is_ok:
|
|
86
|
+
return cast(T, self._value)
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
def map(self, func: Callable[[T], U]) -> Result[U]:
|
|
90
|
+
if self._is_ok:
|
|
91
|
+
return Result(True, func(cast(T, self._value)))
|
|
92
|
+
return Result(False, cast(EnvironmentErrors, self._value))
|
|
93
|
+
|
|
94
|
+
def map_err(self, func: Callable[[EnvironmentErrors], EnvironmentErrors]) -> Result[T]:
|
|
95
|
+
if not self._is_ok:
|
|
96
|
+
return Result(False, func(cast(EnvironmentErrors, self._value)))
|
|
97
|
+
return Result(True, cast(T, self._value))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def Ok(value: TValue) -> Result[TValue]:
|
|
101
|
+
return Result(True, value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def Err(error: EnvironmentErrors) -> Result[Never]:
|
|
105
|
+
return Result(False, error)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def unwrap_to_error(func: Callable[P, Result[T]]) -> Callable[P, Result[T]]:
|
|
109
|
+
|
|
110
|
+
@functools.wraps(func)
|
|
111
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T]:
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return func(*args, **kwargs)
|
|
115
|
+
except UnwrapError as e:
|
|
116
|
+
return Err(e.errors)
|
|
117
|
+
|
|
118
|
+
return wrapper
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Application settings bound before command-line parsing in the active context."""
|
|
2
|
+
|
|
3
|
+
from typing import NewType
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.core.context import ContextValue
|
|
8
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
9
|
+
from llm_tool_cli.core.errors import ContextValueAlreadySet, ToolLabelAlreadyInitialized
|
|
10
|
+
|
|
11
|
+
ToolLabel = NewType("ToolLabel", str)
|
|
12
|
+
ConfigFilename = NewType("ConfigFilename", str)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StaticConfig(BaseEntity):
|
|
16
|
+
"""Application identity available before command-line parsing."""
|
|
17
|
+
|
|
18
|
+
model_config = pydantic.ConfigDict(str_strip_whitespace=False)
|
|
19
|
+
|
|
20
|
+
tool_label: ToolLabel
|
|
21
|
+
config_filename: ConfigFilename | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
static_config = ContextValue[StaticConfig]("static_config")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def initialize(*, label: ToolLabel, config_filename: ConfigFilename | None = None) -> None:
|
|
28
|
+
value = StaticConfig(tool_label=label, config_filename=config_filename)
|
|
29
|
+
if static_config.is_set():
|
|
30
|
+
current = static_config.get()
|
|
31
|
+
if current.tool_label != label:
|
|
32
|
+
raise ToolLabelAlreadyInitialized(details={"current": current.tool_label, "requested": label})
|
|
33
|
+
if current != value:
|
|
34
|
+
raise ContextValueAlreadySet(details={"name": "static_config"})
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
static_config.initialize(value)
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pytest_mock import MockerFixture
|
|
3
|
+
|
|
4
|
+
from llm_tool_cli.core import settings
|
|
5
|
+
from llm_tool_cli.core.context import ContextValue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def isolated_settings(mocker: MockerFixture) -> None:
|
|
10
|
+
"""Start with unset static settings and restore the previous state after the test."""
|
|
11
|
+
# Approved test-only reset: replace the binding because production initialization
|
|
12
|
+
# cannot clear it. Keep real initialization and access; pytest-mock restores it.
|
|
13
|
+
mocker.patch.object(settings, "static_config", ContextValue[settings.StaticConfig]("static_config"))
|