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,152 @@
1
+ from contextvars import Context
2
+
3
+ import pytest
4
+
5
+ from llm_tool_cli.core.context import ContextValue
6
+ from llm_tool_cli.core.errors import ContextValueAlreadySet, ContextValueNotSet
7
+
8
+
9
+ class TestContextValue:
10
+ def test_get__missing_value_identifies_binding(self) -> None:
11
+ value = ContextValue[str]("workspace")
12
+
13
+ with pytest.raises(ContextValueNotSet) as caught:
14
+ value.get()
15
+
16
+ assert caught.value.details == {"name": "workspace"}
17
+ assert not value.is_set()
18
+
19
+ @pytest.mark.parametrize("initial", [None, False, 0, "", " café 日本語 ", []])
20
+ def test_initialize__preserves_value_in_current_context(self, initial: object) -> None:
21
+ value = ContextValue[object]("setting")
22
+ execution = Context()
23
+
24
+ execution.run(value.initialize, initial)
25
+
26
+ assert execution.run(value.is_set)
27
+ assert execution.run(value.get) == initial
28
+ assert not value.is_set()
29
+
30
+ @pytest.mark.parametrize(
31
+ ("initial", "replacement"),
32
+ [("first", "second"), ("first", "first"), (None, None), (None, "second")],
33
+ )
34
+ def test_initialize__rejects_reassignment_and_preserves_value(
35
+ self, initial: str | None, replacement: str | None
36
+ ) -> None:
37
+ value = ContextValue[str | None]("setting")
38
+ execution = Context()
39
+ execution.run(value.initialize, initial)
40
+
41
+ with pytest.raises(ContextValueAlreadySet) as caught:
42
+ execution.run(value.initialize, replacement)
43
+
44
+ assert caught.value.details == {"name": "setting"}
45
+ assert execution.run(value.get) == initial
46
+
47
+ def test_initialize__rejects_assignment_during_scope(self) -> None:
48
+ value = ContextValue[str]("setting")
49
+
50
+ with value.scope("temporary"):
51
+ with pytest.raises(ContextValueAlreadySet):
52
+ value.initialize("replacement")
53
+
54
+ assert value.get() == "temporary"
55
+
56
+ execution = Context()
57
+ execution.run(value.initialize, "permanent")
58
+ assert execution.run(value.get) == "permanent"
59
+
60
+ def test_scope__binds_on_entry_and_restores_unset_state(self) -> None:
61
+ value = ContextValue[str]("setting")
62
+ scope = value.scope("temporary")
63
+ assert not value.is_set()
64
+
65
+ with scope:
66
+ assert value.is_set()
67
+ assert value.get() == "temporary"
68
+
69
+ assert not value.is_set()
70
+ with pytest.raises(ContextValueNotSet):
71
+ value.get()
72
+
73
+ def test_scope__restores_initialized_value(self) -> None:
74
+ value = ContextValue[str]("setting")
75
+ execution = Context()
76
+ execution.run(value.initialize, "initial")
77
+
78
+ def override() -> None:
79
+ with value.scope("temporary"):
80
+ assert value.get() == "temporary"
81
+
82
+ assert value.get() == "initial"
83
+
84
+ execution.run(override)
85
+ assert not value.is_set()
86
+
87
+ @pytest.mark.parametrize("outer", [None, "outer"])
88
+ def test_scope__nested_scopes_restore_previous_values(self, outer: str | None) -> None:
89
+ value = ContextValue[str | None]("operation")
90
+
91
+ with value.scope(outer):
92
+ with value.scope("middle"):
93
+ with value.scope(None):
94
+ assert value.is_set()
95
+ assert value.get() is None
96
+
97
+ assert value.get() == "middle"
98
+
99
+ assert value.is_set()
100
+ assert value.get() == outer
101
+
102
+ assert not value.is_set()
103
+
104
+ @pytest.mark.parametrize("error", [ValueError("failure"), SystemExit(1)])
105
+ def test_scope__restores_previous_value_after_exception(self, error: BaseException) -> None:
106
+ value = ContextValue[str]("setting")
107
+
108
+ with value.scope("outer"):
109
+ with pytest.raises(type(error)) as caught:
110
+ with value.scope("inner"):
111
+ raise error
112
+
113
+ assert caught.value == error
114
+ assert value.get() == "outer"
115
+
116
+ assert not value.is_set()
117
+
118
+ def test_scope__restores_unset_state_after_exception(self) -> None:
119
+ value = ContextValue[str]("setting")
120
+
121
+ with pytest.raises(ValueError):
122
+ with value.scope("temporary"):
123
+ raise ValueError("failure")
124
+
125
+ assert not value.is_set()
126
+ with pytest.raises(ContextValueNotSet):
127
+ value.get()
128
+
129
+ def test_scope__same_name_does_not_share_bindings(self) -> None:
130
+ first = ContextValue[str]("setting")
131
+ second = ContextValue[str]("setting")
132
+
133
+ with first.scope("first"):
134
+ assert not second.is_set()
135
+ with second.scope("second"):
136
+ assert first.get() == "first"
137
+ assert second.get() == "second"
138
+
139
+ assert first.get() == "first"
140
+ assert not second.is_set()
141
+
142
+ assert not first.is_set()
143
+
144
+ def test_scope__preserves_object_mutations(self) -> None:
145
+ value = ContextValue[list[str]]("cache")
146
+ original: list[str] = []
147
+
148
+ with value.scope(original):
149
+ value.get().append("loaded")
150
+ assert original == ["loaded"]
151
+
152
+ assert original == ["loaded"]
@@ -0,0 +1,65 @@
1
+ import pydantic
2
+ import pytest
3
+
4
+ from llm_tool_cli.core.entities import BaseEntity
5
+
6
+
7
+ class _NestedEntity(BaseEntity):
8
+ values: list[int]
9
+
10
+
11
+ class _SampleEntity(BaseEntity):
12
+ name: str
13
+ nested: _NestedEntity
14
+
15
+
16
+ class TestBaseEntity:
17
+ def test_model_config__strips_strings(self) -> None:
18
+ entity = _SampleEntity(name=" value ", nested=_NestedEntity(values=[]))
19
+
20
+ assert entity.name == "value"
21
+
22
+ def test_model_config__forbids_extra_fields(self) -> None:
23
+ with pytest.raises(pydantic.ValidationError):
24
+ _SampleEntity.model_validate({"name": "value", "nested": {"values": []}, "extra": True})
25
+
26
+ def test_model_config__is_frozen(self) -> None:
27
+ entity = _SampleEntity(name="value", nested=_NestedEntity(values=[]))
28
+
29
+ with pytest.raises(pydantic.ValidationError):
30
+ setattr(entity, "name", "changed")
31
+
32
+ @pytest.mark.parametrize("changes", [{}, {"name": "after"}])
33
+ def test_replace__returns_deep_copy_with_changes(self, changes: dict[str, object]) -> None:
34
+ entity = _SampleEntity(name="before", nested=_NestedEntity(values=[1]))
35
+
36
+ replaced = entity.replace(**changes)
37
+ replaced.nested.values.append(2)
38
+
39
+ assert entity.name == "before"
40
+ assert entity.nested.values == [1]
41
+ assert replaced.name == changes.get("name", "before")
42
+ assert replaced.nested.values == [1, 2]
43
+
44
+ def test_replace__does_not_normalize_trusted_changes(self) -> None:
45
+ entity = _SampleEntity(name="before", nested=_NestedEntity(values=[]))
46
+
47
+ assert entity.replace(name=" after ").name == " after "
48
+
49
+ def test_to_json__serializes_entity(self) -> None:
50
+ entity = _SampleEntity(name="value", nested=_NestedEntity(values=[1, 2]))
51
+
52
+ assert (
53
+ entity.to_json()
54
+ == '{\n "name": "value",\n "nested": {\n "values": [\n 1,\n 2\n ]\n }\n}'
55
+ )
56
+
57
+ def test_from_json__deserializes_and_validates_entity(self) -> None:
58
+ entity = _SampleEntity.from_json('{"name": " value ", "nested": {"values": [1]}}')
59
+
60
+ assert entity == _SampleEntity(name="value", nested=_NestedEntity(values=[1]))
61
+
62
+ @pytest.mark.parametrize("json_data", ["{", '{"name": "value", "nested": {"values": ["invalid"]}}'])
63
+ def test_from_json__rejects_invalid_input(self, json_data: str) -> None:
64
+ with pytest.raises(pydantic.ValidationError):
65
+ _SampleEntity.from_json(json_data)
@@ -0,0 +1,211 @@
1
+ from pathlib import Path
2
+ from types import MappingProxyType
3
+ from typing import ClassVar
4
+
5
+ import pytest
6
+
7
+ from llm_tool_cli.core.entities import ExitCode
8
+ from llm_tool_cli.core.errors import (
9
+ EnvironmentError,
10
+ EnvironmentErrors,
11
+ EnvironmentErrorsProxy,
12
+ InternalError,
13
+ exit_code_for_errors,
14
+ )
15
+
16
+
17
+ class BrokenState(InternalError):
18
+ message_template: ClassVar[str] = "Broken {thing}"
19
+
20
+
21
+ class TestInternalError:
22
+ @pytest.mark.parametrize("message", [None, "", "operation failed"])
23
+ def test_init__message_fallback(self, message: str | None) -> None:
24
+ error = InternalError(message)
25
+ expected = "InternalError" if message is None else message
26
+
27
+ assert error.message == expected
28
+ assert str(error) == expected
29
+ assert error.args == (expected,)
30
+
31
+ def test_init__formats_template_from_details(self) -> None:
32
+ details: dict[str, object] = {"thing": "state {literal}"}
33
+ error = BrokenState(details=MappingProxyType(details))
34
+
35
+ assert error.details == details
36
+ assert error.message == "Broken state {literal}"
37
+ assert str(error) == error.message
38
+ assert error.args == (error.message,)
39
+
40
+ @pytest.mark.parametrize("message", ["", "literal {thing}", "{"])
41
+ def test_init__explicit_message_overrides_template(self, message: str) -> None:
42
+ error = BrokenState(message)
43
+
44
+ assert error.message == message
45
+ assert str(error) == message
46
+ assert error.args == (message,)
47
+
48
+ @pytest.mark.parametrize("template", ["Invalid state", ""])
49
+ def test_init__constant_template_without_details(self, template: str) -> None:
50
+ class InvalidState(InternalError):
51
+ message_template: ClassVar[str] = template
52
+
53
+ error = InvalidState()
54
+
55
+ assert error.message == template
56
+ assert error.details == {}
57
+
58
+ def test_init__missing_template_context_raises(self) -> None:
59
+ with pytest.raises(KeyError, match="thing"):
60
+ BrokenState()
61
+
62
+ @pytest.mark.parametrize("details", [None, {}])
63
+ def test_init__empty_details_are_independent(self, details: dict[str, object] | None) -> None:
64
+ first = InternalError(details=details)
65
+ second = InternalError(details=details)
66
+ first.details["path"] = "config.toml"
67
+
68
+ assert not second.details
69
+ if details is not None:
70
+ assert not details
71
+
72
+ def test_init__copies_mapping(self) -> None:
73
+ details: dict[str, object] = {"path": "original.toml"}
74
+ error = InternalError(details=MappingProxyType(details))
75
+ details["path"] = "changed.toml"
76
+ details["extra"] = True
77
+
78
+ assert error.details == {"path": "original.toml"}
79
+
80
+ def test_init__nested_values_remain_shared(self) -> None:
81
+ items = ["original"]
82
+ error = InternalError(details={"items": items})
83
+ items.append("added")
84
+
85
+ assert error.details["items"] == ["original", "added"]
86
+
87
+ def test_message__uses_exception_arguments(self) -> None:
88
+ error = InternalError("original")
89
+ error.args = ("updated",)
90
+
91
+ assert error.message == str(error) == "updated"
92
+
93
+
94
+ class ResourceUnavailable(EnvironmentError):
95
+ code: str = "resource_unavailable"
96
+ message: str = "Resource {error.path}: {error.reason}"
97
+ path: Path
98
+ reason: str
99
+
100
+
101
+ class ReportedProblem(ResourceUnavailable):
102
+ cli_exit_code: ClassVar[ExitCode] = ExitCode.success
103
+
104
+
105
+ class ArgumentRejected(ResourceUnavailable):
106
+ cli_exit_code: ClassVar[ExitCode] = ExitCode.invalid_arguments
107
+
108
+
109
+ class ConfigurationRejected(ResourceUnavailable):
110
+ cli_exit_code: ClassVar[ExitCode] = ExitCode.configuration_error
111
+
112
+
113
+ class TestEnvironmentError:
114
+ @pytest.mark.parametrize("error_type", [ResourceUnavailable, ReportedProblem])
115
+ def test_cli_exit_code__is_not_serialized(self, error_type: type[ResourceUnavailable]) -> None:
116
+ error = error_type(path=Path("resource.txt"), reason="denied")
117
+
118
+ assert "cli_exit_code" not in error.model_dump()
119
+ assert "cli_exit_code" not in error.as_record()
120
+ assert error_type.from_json(error.to_json()).cli_exit_code == error.cli_exit_code
121
+
122
+ def test_format_message__substitutes_typed_context(self) -> None:
123
+ error = ResourceUnavailable(path=Path("resource.txt"), reason="access denied")
124
+
125
+ assert error.format_message() == "Resource resource.txt: access denied"
126
+
127
+ def test_format_message__uses_normalized_context(self) -> None:
128
+ error = ResourceUnavailable(
129
+ path=Path("resource.txt"),
130
+ message=" Resource {error.path}: {error.reason}\n",
131
+ reason=" café\n {literal}\n",
132
+ )
133
+
134
+ assert error.format_message() == "Resource resource.txt: café\n {literal}"
135
+
136
+ def test_as_record__serializes_context_without_template_fields(self) -> None:
137
+ error = ResourceUnavailable(
138
+ path=Path("resource.txt"), reason="access denied", ways_to_fix=["Check {error.path}"]
139
+ )
140
+
141
+ assert error.as_record() == {
142
+ "type": "error",
143
+ "code": "resource_unavailable",
144
+ "message": "Resource resource.txt: access denied",
145
+ "path": "resource.txt",
146
+ "reason": "access denied",
147
+ }
148
+
149
+ def test_with_cause__keeps_debugging_context_private(self) -> None:
150
+ cause = PermissionError("access denied")
151
+ error = ResourceUnavailable(path=Path("resource.txt"), reason=str(cause))
152
+ original_record = error.as_record()
153
+
154
+ returned = error.with_cause(cause)
155
+
156
+ assert returned == error
157
+ assert error.cause == cause
158
+ assert error.as_record() == original_record
159
+ assert "cause" not in error.model_dump_json()
160
+
161
+ def test_cause__without_original_exception(self) -> None:
162
+ error = ResourceUnavailable(path=Path("resource.txt"), reason="not configured")
163
+
164
+ assert error.cause is None
165
+
166
+
167
+ class TestExitCodeForErrors:
168
+ @pytest.mark.parametrize(
169
+ ("error_types", "expected"),
170
+ [
171
+ ([], 0),
172
+ ([ReportedProblem, ReportedProblem], 0),
173
+ ([ResourceUnavailable], 3),
174
+ ([ArgumentRejected], 1),
175
+ ([ConfigurationRejected], 2),
176
+ ([ReportedProblem, ArgumentRejected], 1),
177
+ ([ArgumentRejected, ConfigurationRejected], 2),
178
+ ([ConfigurationRejected, ArgumentRejected], 2),
179
+ ([ArgumentRejected, ConfigurationRejected, ResourceUnavailable, ReportedProblem], 3),
180
+ ([ReportedProblem, ResourceUnavailable, ConfigurationRejected, ArgumentRejected], 3),
181
+ ],
182
+ )
183
+ def test_highest_code_without_changing_errors(
184
+ self, error_types: list[type[ResourceUnavailable]], expected: int
185
+ ) -> None:
186
+ errors: EnvironmentErrors = [
187
+ error_type(path=Path(f"resource-{index}.txt"), reason="denied")
188
+ for index, error_type in enumerate(error_types)
189
+ ]
190
+ records = [error.as_record() for error in errors]
191
+
192
+ result = exit_code_for_errors(errors)
193
+
194
+ assert result == expected
195
+ assert [error.as_record() for error in errors] == records
196
+
197
+
198
+ class TestEnvironmentErrorsProxy:
199
+ @pytest.mark.parametrize("reasons", [[], ["unavailable", "access denied"]])
200
+ def test_init__retains_original_error_list(self, reasons: list[str]) -> None:
201
+ errors: EnvironmentErrors = [
202
+ ResourceUnavailable(path=Path("resource.txt"), reason=reason) for reason in reasons
203
+ ]
204
+
205
+ proxy = EnvironmentErrorsProxy(errors)
206
+
207
+ assert proxy.details == {"errors": errors}
208
+
209
+ errors.append(ResourceUnavailable(path=Path("other.txt"), reason="missing"))
210
+
211
+ assert proxy.details == {"errors": errors}
@@ -0,0 +1,256 @@
1
+ import pytest
2
+
3
+ from llm_tool_cli.core.errors import EnvironmentError, EnvironmentErrors, InternalError
4
+ from llm_tool_cli.core.result import Err, Ok, Result, UnwrapErrError, UnwrapError, unwrap_to_error
5
+
6
+
7
+ class ExpectedFailure(EnvironmentError):
8
+ code: str = "expected_failure"
9
+ message: str = "Expected failure"
10
+
11
+
12
+ class SpecificFailure(ExpectedFailure):
13
+ code: str = "specific_failure"
14
+
15
+
16
+ class OtherFailure(EnvironmentError):
17
+ code: str = "other_failure"
18
+ message: str = "Other failure"
19
+
20
+
21
+ class TestUnwrapError:
22
+ def test_errors__preserves_list_values_and_order(self) -> None:
23
+ failures: EnvironmentErrors = [SpecificFailure(), OtherFailure()]
24
+ exception = UnwrapError(error=failures)
25
+
26
+ recovered = exception.errors
27
+
28
+ assert recovered == [SpecificFailure(), OtherFailure()]
29
+ recovered.append(ExpectedFailure())
30
+ assert failures == [SpecificFailure(), OtherFailure(), ExpectedFailure()]
31
+
32
+ def test_errors__accepts_empty_list(self) -> None:
33
+ assert UnwrapError(error=[]).errors == []
34
+
35
+ def test_errors__reads_current_details_payload(self) -> None:
36
+ exception = UnwrapError(error=[ExpectedFailure()])
37
+ exception.details["error"] = [OtherFailure()]
38
+
39
+ assert exception.errors == [OtherFailure()]
40
+
41
+ @pytest.mark.parametrize(
42
+ "payload",
43
+ [
44
+ None,
45
+ "unexpected payload",
46
+ ExpectedFailure(),
47
+ (ExpectedFailure(),),
48
+ iter([ExpectedFailure()]),
49
+ [ExpectedFailure(), "unexpected payload"],
50
+ ],
51
+ )
52
+ def test_errors__rejects_malformed_payload(self, payload: object) -> None:
53
+ exception = UnwrapError(error=[])
54
+ exception.details["error"] = payload
55
+
56
+ with pytest.raises(UnwrapError) as caught:
57
+ _ = exception.errors
58
+
59
+ assert caught.value == exception
60
+ assert caught.value.details == {"error": payload}
61
+
62
+ def test_errors__rejects_missing_payload(self) -> None:
63
+ exception = UnwrapError(error=[])
64
+ del exception.details["error"]
65
+
66
+ with pytest.raises(UnwrapError) as caught:
67
+ _ = exception.errors
68
+
69
+ assert caught.value == exception
70
+ assert caught.value.details == {}
71
+
72
+
73
+ class TestResult:
74
+ @pytest.mark.parametrize("errors", [[], [ExpectedFailure()], [ExpectedFailure(), OtherFailure()]])
75
+ def test_is_err__without_type_accepts_any_error_list(self, errors: EnvironmentErrors) -> None:
76
+ result: Result[int] = Err(errors)
77
+
78
+ assert result.is_err()
79
+ assert result.is_err(None)
80
+ assert result.unwrap_err() == errors
81
+
82
+ @pytest.mark.parametrize("value", [None, 1])
83
+ def test_is_err__success_never_matches(self, value: int | None) -> None:
84
+ result: Result[int | None] = Ok(value)
85
+
86
+ assert not result.is_err()
87
+ assert not result.is_err(ExpectedFailure)
88
+ assert result.unwrap() == value
89
+
90
+ @pytest.mark.parametrize(
91
+ "errors",
92
+ [[ExpectedFailure()], [SpecificFailure()], [ExpectedFailure(), SpecificFailure()]],
93
+ )
94
+ def test_is_err__matches_all_errors_including_subclasses(self, errors: EnvironmentErrors) -> None:
95
+ result: Result[int] = Err(errors)
96
+
97
+ assert result.is_err(ExpectedFailure)
98
+ assert result.unwrap_err() == errors
99
+
100
+ @pytest.mark.parametrize(
101
+ "errors",
102
+ [[OtherFailure()], [ExpectedFailure(), OtherFailure()], [OtherFailure(), ExpectedFailure()]],
103
+ )
104
+ def test_is_err__rejects_unmatched_and_mixed_errors(self, errors: EnvironmentErrors) -> None:
105
+ result: Result[int] = Err(errors)
106
+
107
+ assert result.is_err()
108
+ assert not result.is_err(ExpectedFailure)
109
+ assert result.unwrap_err() == errors
110
+
111
+ def test_is_err__empty_error_list_does_not_match_type(self) -> None:
112
+ result: Result[int] = Err([])
113
+
114
+ assert result.is_err()
115
+ assert not result.is_err(ExpectedFailure)
116
+
117
+ def test_unwrap__none_is_a_success_value(self) -> None:
118
+ result: Result[None] = Ok(None)
119
+
120
+ assert result.is_ok()
121
+ assert result.unwrap() is None
122
+
123
+ def test_ok__exposes_success_value(self) -> None:
124
+ result: Result[int] = Ok(2)
125
+
126
+ assert result.is_ok()
127
+ assert not result.is_err()
128
+ assert result.ok() == 2
129
+ assert result.err() is None
130
+ assert result.unwrap() == 2
131
+ assert result.unwrap_or(3) == 2
132
+
133
+ def test_ok__maps_success_value(self) -> None:
134
+ result: Result[int] = Ok(2)
135
+
136
+ mapped = result.map(lambda value: value + 1)
137
+ mapped_error = result.map_err(lambda errors: pytest.fail("must not transform errors on success"))
138
+
139
+ assert mapped.unwrap() == 3
140
+ assert mapped_error.unwrap() == 2
141
+
142
+ def test_ok__unwrap_err_raises_internal_error(self) -> None:
143
+ result: Result[int] = Ok(2)
144
+
145
+ with pytest.raises(UnwrapErrError) as exc_info:
146
+ result.unwrap_err()
147
+
148
+ assert exc_info.value.details == {"value": 2}
149
+ assert exc_info.value.message == str(exc_info.value)
150
+
151
+ def test_err__exposes_error_value(self) -> None:
152
+ errors: EnvironmentErrors = [ExpectedFailure(), OtherFailure()]
153
+ result: Result[int] = Err(errors)
154
+
155
+ assert not result.is_ok()
156
+ assert result.is_err()
157
+ assert result.ok() is None
158
+ assert result.err() == errors
159
+ assert result.unwrap_or(3) == 3
160
+
161
+ def test_err__maps_error_value(self) -> None:
162
+ result: Result[int] = Err([ExpectedFailure()])
163
+
164
+ mapped = result.map(lambda value: pytest.fail("must not transform a value on failure"))
165
+ mapped_error = result.map_err(lambda errors: [*errors, OtherFailure()])
166
+
167
+ assert mapped.err() == [ExpectedFailure()]
168
+ assert mapped_error.err() == [ExpectedFailure(), OtherFailure()]
169
+ assert result.err() == [ExpectedFailure()]
170
+
171
+ def test_err__unwrap_raises_internal_error_with_error_value(self) -> None:
172
+ errors: EnvironmentErrors = [ExpectedFailure(), OtherFailure()]
173
+ result: Result[int] = Err(errors)
174
+
175
+ with pytest.raises(UnwrapError) as exc_info:
176
+ result.unwrap()
177
+
178
+ assert exc_info.value.details == {"error": errors}
179
+ assert exc_info.value.message == str(exc_info.value)
180
+
181
+
182
+ class TestOk:
183
+ def test_returns_success_result(self) -> None:
184
+ assert Ok("value").unwrap() == "value"
185
+
186
+
187
+ class TestErr:
188
+ def test_returns_error_result(self) -> None:
189
+ errors: EnvironmentErrors = [ExpectedFailure(), OtherFailure()]
190
+
191
+ assert Err(errors).unwrap_err() == errors
192
+
193
+
194
+ class TestUnwrapToError:
195
+ def test_short_circuits_composition_without_losing_errors(self) -> None:
196
+ calls = []
197
+
198
+ @unwrap_to_error
199
+ def composed() -> Result[str]:
200
+ calls.append("before")
201
+ result: Result[str] = Err([ExpectedFailure(), OtherFailure()])
202
+ value = result.unwrap()
203
+ calls.append("after")
204
+ return Ok(value)
205
+
206
+ assert composed().unwrap_err() == [ExpectedFailure(), OtherFailure()]
207
+ assert calls == ["before"]
208
+
209
+ def test_does_not_hide_unwrap_err_misuse(self) -> None:
210
+ @unwrap_to_error
211
+ def composed() -> Result[str]:
212
+ Ok("value").unwrap_err()
213
+ return Ok("unreachable")
214
+
215
+ with pytest.raises(UnwrapErrError):
216
+ composed()
217
+
218
+ @pytest.mark.parametrize("errors", [[], [ExpectedFailure(), SpecificFailure(), OtherFailure()]])
219
+ def test_converts_unwrap_error_to_error_result(self, errors: EnvironmentErrors) -> None:
220
+ @unwrap_to_error
221
+ def composed() -> Result[str]:
222
+ return Err(errors).unwrap()
223
+
224
+ result = composed()
225
+
226
+ assert result.is_err()
227
+ assert result.unwrap_err() == errors
228
+
229
+ def test_malformed_unwrap_payload_remains_an_exception(self) -> None:
230
+ exception = UnwrapError(error=[])
231
+ exception.details["error"] = [ExpectedFailure(), "unexpected payload"]
232
+
233
+ @unwrap_to_error
234
+ def composed() -> Result[str]:
235
+ raise exception
236
+
237
+ with pytest.raises(UnwrapError) as caught:
238
+ composed()
239
+
240
+ assert caught.value == exception
241
+
242
+ def test_preserves_success_result(self) -> None:
243
+ @unwrap_to_error
244
+ def composed() -> Result[str]:
245
+ return Ok("value")
246
+
247
+ assert composed().unwrap() == "value"
248
+
249
+ @pytest.mark.parametrize("error", [RuntimeError("boom"), InternalError("technical failure")])
250
+ def test_does_not_hide_arbitrary_exceptions(self, error: Exception) -> None:
251
+ @unwrap_to_error
252
+ def composed() -> Result[str]:
253
+ raise error
254
+
255
+ with pytest.raises(type(error)):
256
+ composed()