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.
Files changed (111) hide show
  1. llm_tool_cli/__init__.py +0 -0
  2. llm_tool_cli/cli/__init__.py +1 -0
  3. llm_tool_cli/cli/application.py +31 -0
  4. llm_tool_cli/cli/commands/__init__.py +0 -0
  5. llm_tool_cli/cli/commands/initialization.py +36 -0
  6. llm_tool_cli/cli/commands/skills.py +25 -0
  7. llm_tool_cli/cli/commands/tests/__init__.py +0 -0
  8. llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
  9. llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
  10. llm_tool_cli/cli/commands/tests/test_version.py +98 -0
  11. llm_tool_cli/cli/commands/version.py +17 -0
  12. llm_tool_cli/cli/context.py +39 -0
  13. llm_tool_cli/cli/entities.py +3 -0
  14. llm_tool_cli/cli/errors.py +15 -0
  15. llm_tool_cli/cli/handling.py +41 -0
  16. llm_tool_cli/cli/options.py +48 -0
  17. llm_tool_cli/cli/tests/__init__.py +0 -0
  18. llm_tool_cli/cli/tests/test_application.py +167 -0
  19. llm_tool_cli/cli/tests/test_context.py +179 -0
  20. llm_tool_cli/cli/tests/test_handling.py +230 -0
  21. llm_tool_cli/cli/tests/test_options.py +125 -0
  22. llm_tool_cli/config/__init__.py +33 -0
  23. llm_tool_cli/config/errors.py +92 -0
  24. llm_tool_cli/config/fields.py +39 -0
  25. llm_tool_cli/config/files.py +175 -0
  26. llm_tool_cli/config/project.py +87 -0
  27. llm_tool_cli/config/runtime.py +14 -0
  28. llm_tool_cli/config/tests/__init__.py +0 -0
  29. llm_tool_cli/config/tests/fixtures.py +34 -0
  30. llm_tool_cli/config/tests/test_fields.py +124 -0
  31. llm_tool_cli/config/tests/test_files.py +826 -0
  32. llm_tool_cli/config/tests/test_project.py +257 -0
  33. llm_tool_cli/core/__init__.py +1 -0
  34. llm_tool_cli/core/context.py +50 -0
  35. llm_tool_cli/core/entities.py +35 -0
  36. llm_tool_cli/core/errors.py +97 -0
  37. llm_tool_cli/core/result.py +118 -0
  38. llm_tool_cli/core/settings.py +37 -0
  39. llm_tool_cli/core/tests/__init__.py +0 -0
  40. llm_tool_cli/core/tests/fixtures.py +13 -0
  41. llm_tool_cli/core/tests/test_context.py +152 -0
  42. llm_tool_cli/core/tests/test_entities.py +65 -0
  43. llm_tool_cli/core/tests/test_errors.py +211 -0
  44. llm_tool_cli/core/tests/test_result.py +256 -0
  45. llm_tool_cli/core/tests/test_settings.py +64 -0
  46. llm_tool_cli/output/__init__.py +3 -0
  47. llm_tool_cli/output/emitter.py +108 -0
  48. llm_tool_cli/output/errors.py +21 -0
  49. llm_tool_cli/output/journal.py +66 -0
  50. llm_tool_cli/output/tests/__init__.py +0 -0
  51. llm_tool_cli/output/tests/helpers.py +29 -0
  52. llm_tool_cli/output/tests/test_emitter.py +406 -0
  53. llm_tool_cli/output/tests/test_journal.py +141 -0
  54. llm_tool_cli/paths/__init__.py +51 -0
  55. llm_tool_cli/paths/enumeration.py +72 -0
  56. llm_tool_cli/paths/errors.py +42 -0
  57. llm_tool_cli/paths/filesystem.py +164 -0
  58. llm_tool_cli/paths/globs.py +124 -0
  59. llm_tool_cli/paths/normalization.py +58 -0
  60. llm_tool_cli/paths/tests/__init__.py +0 -0
  61. llm_tool_cli/paths/tests/test_enumeration.py +229 -0
  62. llm_tool_cli/paths/tests/test_filesystem.py +778 -0
  63. llm_tool_cli/paths/tests/test_globs.py +137 -0
  64. llm_tool_cli/paths/tests/test_normalization.py +163 -0
  65. llm_tool_cli/protocol/__init__.py +4 -0
  66. llm_tool_cli/protocol/cell_shortcuts.py +39 -0
  67. llm_tool_cli/protocol/entities.py +7 -0
  68. llm_tool_cli/protocol/errors.py +21 -0
  69. llm_tool_cli/protocol/journal.py +21 -0
  70. llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
  71. llm_tool_cli/protocol/logic_cells/base.py +41 -0
  72. llm_tool_cli/protocol/logic_cells/content.py +26 -0
  73. llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
  74. llm_tool_cli/protocol/logic_cells/journal.py +44 -0
  75. llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
  76. llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
  77. llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
  78. llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
  79. llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
  80. llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
  81. llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
  82. llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
  83. llm_tool_cli/protocol/logic_cells/warning.py +22 -0
  84. llm_tool_cli/protocol/output_cells/__init__.py +11 -0
  85. llm_tool_cli/protocol/output_cells/automation.py +14 -0
  86. llm_tool_cli/protocol/output_cells/base.py +65 -0
  87. llm_tool_cli/protocol/output_cells/human.py +24 -0
  88. llm_tool_cli/protocol/output_cells/journal.py +23 -0
  89. llm_tool_cli/protocol/output_cells/llm.py +25 -0
  90. llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
  91. llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
  92. llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
  93. llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
  94. llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
  95. llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
  96. llm_tool_cli/protocol/serialization.py +7 -0
  97. llm_tool_cli/protocol/tests/__init__.py +0 -0
  98. llm_tool_cli/protocol/tests/helpers.py +25 -0
  99. llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
  100. llm_tool_cli/protocol/tests/test_journal.py +44 -0
  101. llm_tool_cli/protocol/tests/test_serialization.py +33 -0
  102. llm_tool_cli/py.typed +0 -0
  103. llm_tool_cli/skills/__init__.py +6 -0
  104. llm_tool_cli/skills/errors.py +12 -0
  105. llm_tool_cli/skills/fixtures.py +14 -0
  106. llm_tool_cli/skills/tests/__init__.py +0 -0
  107. llm_tool_cli/skills/tests/test_fixtures.py +58 -0
  108. llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
  109. llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
  110. llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
  111. 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"))