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,826 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
import pytest
|
|
8
|
+
from pytest_mock import MockerFixture
|
|
9
|
+
|
|
10
|
+
from llm_tool_cli.config import (
|
|
11
|
+
create_config,
|
|
12
|
+
create_config_from_template,
|
|
13
|
+
errors,
|
|
14
|
+
find_config,
|
|
15
|
+
initialize_config,
|
|
16
|
+
load_config,
|
|
17
|
+
locate_config,
|
|
18
|
+
read_toml,
|
|
19
|
+
resolve_config_path,
|
|
20
|
+
resolve_init_config_path,
|
|
21
|
+
)
|
|
22
|
+
from llm_tool_cli.core.errors import EnvironmentErrors
|
|
23
|
+
from llm_tool_cli.core.result import Err, Result
|
|
24
|
+
from llm_tool_cli.paths import PathInput, ProjectConfigPath
|
|
25
|
+
from llm_tool_cli.paths.errors import InvalidProjectPath
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def config_package(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
|
|
30
|
+
package = "config_fixture_package"
|
|
31
|
+
initializer = tmp_path / "__init__.py"
|
|
32
|
+
initializer.write_text("", encoding="utf-8")
|
|
33
|
+
spec = importlib.util.spec_from_file_location(package, initializer)
|
|
34
|
+
assert spec is not None
|
|
35
|
+
monkeypatch.setitem(sys.modules, package, importlib.util.module_from_spec(spec))
|
|
36
|
+
(tmp_path / "fixtures").mkdir()
|
|
37
|
+
return package
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestFindConfig:
|
|
41
|
+
def test_nearest_file(self, tmp_path: Path) -> None:
|
|
42
|
+
nested = tmp_path / "nested"
|
|
43
|
+
nested.mkdir()
|
|
44
|
+
(tmp_path / "config.toml").touch()
|
|
45
|
+
nearest = nested / "config.toml"
|
|
46
|
+
nearest.write_text("not valid TOML", encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
assert find_config("config.toml", nested).unwrap() == nearest
|
|
49
|
+
|
|
50
|
+
def test_searches_parents_and_skips_directories(self, tmp_path: Path) -> None:
|
|
51
|
+
nested = tmp_path / "nested"
|
|
52
|
+
nested.mkdir()
|
|
53
|
+
(nested / "config.toml").mkdir()
|
|
54
|
+
outer = tmp_path / "config.toml"
|
|
55
|
+
outer.touch()
|
|
56
|
+
|
|
57
|
+
assert find_config("config.toml", nested).unwrap() == outer
|
|
58
|
+
|
|
59
|
+
def test_missing(self, tmp_path: Path) -> None:
|
|
60
|
+
assert find_config(f"{tmp_path.name}-missing.toml", tmp_path).unwrap() is None
|
|
61
|
+
|
|
62
|
+
def test_searches_filesystem_root(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
63
|
+
root_config = Path(tmp_path.anchor) / "config.toml"
|
|
64
|
+
mocker.patch.object(Path, "is_file", autospec=True, side_effect=lambda path: path == root_config)
|
|
65
|
+
|
|
66
|
+
assert find_config("config.toml", tmp_path).unwrap() == root_config
|
|
67
|
+
|
|
68
|
+
def test_resolves_relative_start(self, tmp_path: Path) -> None:
|
|
69
|
+
config_path = tmp_path / "config.toml"
|
|
70
|
+
config_path.touch()
|
|
71
|
+
|
|
72
|
+
with contextlib.chdir(tmp_path):
|
|
73
|
+
assert find_config("config.toml", Path(".")).unwrap() == config_path
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("target_kind", ["file", "missing", "loop", "directory"])
|
|
76
|
+
def test_rejects_symlink_without_falling_back_to_parent(self, tmp_path: Path, target_kind: str) -> None:
|
|
77
|
+
nested = tmp_path / "nested"
|
|
78
|
+
nested.mkdir()
|
|
79
|
+
(tmp_path / "config.toml").touch()
|
|
80
|
+
config_path = nested / "config.toml"
|
|
81
|
+
target = config_path if target_kind == "loop" else nested / "target"
|
|
82
|
+
if target_kind == "file":
|
|
83
|
+
target.touch()
|
|
84
|
+
elif target_kind == "directory":
|
|
85
|
+
target.mkdir()
|
|
86
|
+
config_path.symlink_to(target)
|
|
87
|
+
|
|
88
|
+
assert find_config("config.toml", nested).unwrap_err() == [InvalidProjectPath(path=str(config_path))]
|
|
89
|
+
|
|
90
|
+
def test_accepts_start_directory_alias(self, tmp_path: Path) -> None:
|
|
91
|
+
root = tmp_path / "project"
|
|
92
|
+
root.mkdir()
|
|
93
|
+
config_path = root / "config.toml"
|
|
94
|
+
config_path.touch()
|
|
95
|
+
alias = tmp_path / "alias"
|
|
96
|
+
alias.symlink_to(root, target_is_directory=True)
|
|
97
|
+
|
|
98
|
+
assert find_config("config.toml", alias).unwrap() == config_path
|
|
99
|
+
|
|
100
|
+
def test_resolution_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
101
|
+
mocker.patch.object(Path, "resolve", side_effect=OSError("resolution failed"))
|
|
102
|
+
|
|
103
|
+
caught = find_config("config.toml", tmp_path).unwrap_err()[0]
|
|
104
|
+
assert isinstance(caught, errors.DiscoveryFailed)
|
|
105
|
+
|
|
106
|
+
assert caught.code == "config_discovery_failed"
|
|
107
|
+
assert caught.path == tmp_path
|
|
108
|
+
assert isinstance(caught.cause, OSError)
|
|
109
|
+
|
|
110
|
+
def test_inspection_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
111
|
+
mocker.patch.object(Path, "is_file", side_effect=PermissionError("access denied"))
|
|
112
|
+
|
|
113
|
+
caught = find_config("config.toml", tmp_path).unwrap_err()[0]
|
|
114
|
+
assert isinstance(caught, errors.DiscoveryFailed)
|
|
115
|
+
|
|
116
|
+
assert caught.code == "config_discovery_failed"
|
|
117
|
+
assert caught.path == tmp_path / "config.toml"
|
|
118
|
+
assert isinstance(caught.cause, PermissionError)
|
|
119
|
+
assert caught.reason == str(caught.cause)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestResolveConfigPath:
|
|
123
|
+
def test_relative_missing_path(self, tmp_path: Path) -> None:
|
|
124
|
+
assert resolve_config_path(Path("nested/../custom.toml"), tmp_path).unwrap() == tmp_path / "custom.toml"
|
|
125
|
+
|
|
126
|
+
def test_absolute_path_ignores_cwd(self, tmp_path: Path) -> None:
|
|
127
|
+
config_path = tmp_path / "custom.toml"
|
|
128
|
+
|
|
129
|
+
assert resolve_config_path(config_path, tmp_path / "missing").unwrap() == config_path
|
|
130
|
+
|
|
131
|
+
def test_relative_cwd(self, tmp_path: Path) -> None:
|
|
132
|
+
with contextlib.chdir(tmp_path):
|
|
133
|
+
assert resolve_config_path(Path("custom.toml"), Path("nested")).unwrap() == tmp_path / "nested/custom.toml"
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("target_kind", ["file", "missing", "loop"])
|
|
136
|
+
def test_rejects_symlinks(self, tmp_path: Path, target_kind: str) -> None:
|
|
137
|
+
link = tmp_path / "custom.toml"
|
|
138
|
+
target = link if target_kind == "loop" else tmp_path / "target.toml"
|
|
139
|
+
if target_kind == "file":
|
|
140
|
+
target.touch()
|
|
141
|
+
link.symlink_to(target)
|
|
142
|
+
|
|
143
|
+
assert resolve_config_path(link, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(link))]
|
|
144
|
+
|
|
145
|
+
def test_accepts_containing_directory_alias(self, tmp_path: Path) -> None:
|
|
146
|
+
root = tmp_path / "project"
|
|
147
|
+
root.mkdir()
|
|
148
|
+
alias = tmp_path / "alias"
|
|
149
|
+
alias.symlink_to(root, target_is_directory=True)
|
|
150
|
+
|
|
151
|
+
assert resolve_config_path(alias / "config.toml", tmp_path).unwrap() == root / "config.toml"
|
|
152
|
+
|
|
153
|
+
def test_expands_home(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
154
|
+
home = tmp_path / "home"
|
|
155
|
+
mocker.patch.dict("os.environ", {"HOME": str(home)})
|
|
156
|
+
|
|
157
|
+
assert resolve_config_path(Path("~/config.toml"), tmp_path / "cwd").unwrap() == home / "config.toml"
|
|
158
|
+
|
|
159
|
+
def test_preserves_application_syntax(self, tmp_path: Path) -> None:
|
|
160
|
+
assert resolve_config_path(Path("@/config.toml"), tmp_path).unwrap() == tmp_path / "@/config.toml"
|
|
161
|
+
|
|
162
|
+
def test_home_expansion_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
163
|
+
path = Path("~/config.toml")
|
|
164
|
+
mocker.patch.object(Path, "expanduser", side_effect=RuntimeError("home directory unavailable"))
|
|
165
|
+
|
|
166
|
+
caught = resolve_config_path(path, tmp_path).unwrap_err()[0]
|
|
167
|
+
assert isinstance(caught, errors.PathResolutionFailed)
|
|
168
|
+
|
|
169
|
+
assert caught.code == "config_path_resolution_failed"
|
|
170
|
+
assert caught.path == path
|
|
171
|
+
assert isinstance(caught.cause, RuntimeError)
|
|
172
|
+
assert caught.reason == str(caught.cause)
|
|
173
|
+
|
|
174
|
+
def test_reported_symlink_loop(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
175
|
+
link = tmp_path / "loop"
|
|
176
|
+
mocker.patch.object(Path, "resolve", side_effect=RuntimeError("symlink loop"))
|
|
177
|
+
|
|
178
|
+
caught = resolve_config_path(link, tmp_path).unwrap_err()[0]
|
|
179
|
+
assert isinstance(caught, errors.PathResolutionFailed)
|
|
180
|
+
|
|
181
|
+
assert caught.code == "config_path_resolution_failed"
|
|
182
|
+
assert caught.path == link
|
|
183
|
+
assert isinstance(caught.cause, (OSError, RuntimeError))
|
|
184
|
+
|
|
185
|
+
def test_filesystem_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
186
|
+
mocker.patch.object(Path, "resolve", side_effect=PermissionError("access denied"))
|
|
187
|
+
|
|
188
|
+
caught = resolve_config_path(Path("custom.toml"), tmp_path).unwrap_err()[0]
|
|
189
|
+
assert isinstance(caught, errors.PathResolutionFailed)
|
|
190
|
+
|
|
191
|
+
assert caught.code == "config_path_resolution_failed"
|
|
192
|
+
assert caught.path == tmp_path / "custom.toml"
|
|
193
|
+
assert isinstance(caught.cause, PermissionError)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestResolveInitConfigPath:
|
|
197
|
+
def test_default_stays_in_cwd_without_reading_or_creating(self, tmp_path: Path) -> None:
|
|
198
|
+
parent_config = tmp_path / "config.toml"
|
|
199
|
+
parent_config.write_text("invalid TOML [", encoding="utf-8")
|
|
200
|
+
cwd = PathInput(tmp_path / "missing")
|
|
201
|
+
|
|
202
|
+
selected = resolve_init_config_path("config.toml", cwd=cwd).unwrap()
|
|
203
|
+
|
|
204
|
+
assert selected == tmp_path / "missing/config.toml"
|
|
205
|
+
assert not cwd.exists()
|
|
206
|
+
assert parent_config.read_text(encoding="utf-8") == "invalid TOML ["
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize("absolute", [False, True])
|
|
209
|
+
def test_explicit_path_overrides_filename(self, tmp_path: Path, absolute: bool) -> None:
|
|
210
|
+
path = tmp_path / "custom.toml" if absolute else Path("nested/../custom.toml")
|
|
211
|
+
cwd = PathInput(tmp_path / "elsewhere" if absolute else tmp_path)
|
|
212
|
+
|
|
213
|
+
selected = resolve_init_config_path("config.toml", path=ProjectConfigPath(path), cwd=cwd).unwrap()
|
|
214
|
+
|
|
215
|
+
assert selected == tmp_path / "custom.toml"
|
|
216
|
+
assert not selected.exists()
|
|
217
|
+
|
|
218
|
+
def test_home_relative_path(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
219
|
+
home = tmp_path / "home"
|
|
220
|
+
mocker.patch.dict("os.environ", {"HOME": str(home)})
|
|
221
|
+
|
|
222
|
+
selected = resolve_init_config_path(
|
|
223
|
+
"config.toml", path=ProjectConfigPath(Path("~/custom.toml")), cwd=PathInput(tmp_path)
|
|
224
|
+
).unwrap()
|
|
225
|
+
|
|
226
|
+
assert selected == home / "custom.toml"
|
|
227
|
+
assert not home.exists()
|
|
228
|
+
|
|
229
|
+
def test_relative_cwd(self, tmp_path: Path) -> None:
|
|
230
|
+
with contextlib.chdir(tmp_path):
|
|
231
|
+
selected = resolve_init_config_path("config.toml", cwd=PathInput(Path("nested"))).unwrap()
|
|
232
|
+
|
|
233
|
+
assert selected == tmp_path / "nested/config.toml"
|
|
234
|
+
|
|
235
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
236
|
+
def test_rejects_symlink_without_modifying_existing_target(self, tmp_path: Path, explicit: bool) -> None:
|
|
237
|
+
target = tmp_path / "target.toml"
|
|
238
|
+
target.write_text("original", encoding="utf-8")
|
|
239
|
+
link = ProjectConfigPath(tmp_path / "config.toml")
|
|
240
|
+
link.symlink_to(target)
|
|
241
|
+
|
|
242
|
+
result = resolve_init_config_path("config.toml", path=link if explicit else None, cwd=PathInput(tmp_path))
|
|
243
|
+
|
|
244
|
+
assert result.unwrap_err() == [InvalidProjectPath(path=str(link))]
|
|
245
|
+
assert target.read_text(encoding="utf-8") == "original"
|
|
246
|
+
assert link.is_symlink()
|
|
247
|
+
|
|
248
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
249
|
+
def test_resolution_failure_propagates(self, tmp_path: Path, mocker: MockerFixture, explicit: bool) -> None:
|
|
250
|
+
failures: EnvironmentErrors = [
|
|
251
|
+
errors.PathResolutionFailed(path=tmp_path / name, reason="permission denied")
|
|
252
|
+
for name in ("first.toml", "second.toml")
|
|
253
|
+
]
|
|
254
|
+
mocker.patch("llm_tool_cli.config.files.resolve_config_path", return_value=Err(failures))
|
|
255
|
+
|
|
256
|
+
result = resolve_init_config_path(
|
|
257
|
+
"config.toml", path=ProjectConfigPath(Path("custom.toml")) if explicit else None, cwd=PathInput(tmp_path)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
assert result.unwrap_err() == failures
|
|
261
|
+
|
|
262
|
+
def test_unexpected_failure_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
263
|
+
mocker.patch.object(Path, "resolve", side_effect=TypeError("unexpected failure"))
|
|
264
|
+
|
|
265
|
+
with pytest.raises(TypeError, match="unexpected failure"):
|
|
266
|
+
resolve_init_config_path("config.toml", cwd=PathInput(tmp_path))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestInitializeConfig:
|
|
270
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
271
|
+
def test_broken_symlink_does_not_create_its_target(
|
|
272
|
+
self, tmp_path: Path, config_package: str, explicit: bool
|
|
273
|
+
) -> None:
|
|
274
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
275
|
+
target = tmp_path / "target.toml"
|
|
276
|
+
path = ProjectConfigPath(tmp_path / "config.toml")
|
|
277
|
+
path.symlink_to(target)
|
|
278
|
+
|
|
279
|
+
result = initialize_config(
|
|
280
|
+
"config.toml",
|
|
281
|
+
package=config_package,
|
|
282
|
+
template="starter.toml",
|
|
283
|
+
cwd=PathInput(tmp_path),
|
|
284
|
+
path=path if explicit else None,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
assert result.unwrap_err() == [InvalidProjectPath(path=str(path))]
|
|
288
|
+
assert path.is_symlink()
|
|
289
|
+
assert not target.exists()
|
|
290
|
+
|
|
291
|
+
@pytest.mark.parametrize("relative_cwd", [False, True])
|
|
292
|
+
def test_default_path_does_not_reuse_parent_config(
|
|
293
|
+
self, tmp_path: Path, config_package: str, relative_cwd: bool
|
|
294
|
+
) -> None:
|
|
295
|
+
text = "# café\ninvalid TOML ["
|
|
296
|
+
(tmp_path / "fixtures/starter.toml").write_text(text, encoding="utf-8")
|
|
297
|
+
parent_config = tmp_path / "config.toml"
|
|
298
|
+
parent_config.write_text("original", encoding="utf-8")
|
|
299
|
+
project = tmp_path / "project"
|
|
300
|
+
project.mkdir()
|
|
301
|
+
|
|
302
|
+
with contextlib.chdir(tmp_path):
|
|
303
|
+
selected = initialize_config(
|
|
304
|
+
"config.toml",
|
|
305
|
+
package=config_package,
|
|
306
|
+
template="starter.toml",
|
|
307
|
+
cwd=PathInput(Path("project") if relative_cwd else project),
|
|
308
|
+
).unwrap()
|
|
309
|
+
|
|
310
|
+
assert selected == project / "config.toml"
|
|
311
|
+
assert selected.read_text(encoding="utf-8") == text
|
|
312
|
+
assert parent_config.read_text(encoding="utf-8") == "original"
|
|
313
|
+
|
|
314
|
+
@pytest.mark.parametrize("absolute", [False, True])
|
|
315
|
+
def test_explicit_path_overrides_filename(self, tmp_path: Path, config_package: str, absolute: bool) -> None:
|
|
316
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
317
|
+
path = tmp_path / "custom.toml" if absolute else Path("custom.toml")
|
|
318
|
+
|
|
319
|
+
selected = initialize_config(
|
|
320
|
+
"config.toml",
|
|
321
|
+
package=config_package,
|
|
322
|
+
template="starter.toml",
|
|
323
|
+
cwd=PathInput(tmp_path / "elsewhere" if absolute else tmp_path),
|
|
324
|
+
path=ProjectConfigPath(path),
|
|
325
|
+
).unwrap()
|
|
326
|
+
|
|
327
|
+
assert selected == tmp_path / "custom.toml"
|
|
328
|
+
assert selected.read_text(encoding="utf-8") == "version = 1\n"
|
|
329
|
+
assert not (tmp_path / "config.toml").exists()
|
|
330
|
+
|
|
331
|
+
def test_home_relative_path(self, tmp_path: Path, config_package: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
332
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
333
|
+
home_dir = tmp_path / "home"
|
|
334
|
+
home_dir.mkdir()
|
|
335
|
+
monkeypatch.setenv("HOME", str(home_dir))
|
|
336
|
+
|
|
337
|
+
selected = initialize_config(
|
|
338
|
+
"config.toml",
|
|
339
|
+
package=config_package,
|
|
340
|
+
template="starter.toml",
|
|
341
|
+
cwd=PathInput(tmp_path),
|
|
342
|
+
path=ProjectConfigPath(Path("~/custom.toml")),
|
|
343
|
+
).unwrap()
|
|
344
|
+
|
|
345
|
+
assert selected == home_dir / "custom.toml"
|
|
346
|
+
assert selected.read_text(encoding="utf-8") == "version = 1\n"
|
|
347
|
+
|
|
348
|
+
def test_existing_file(self, tmp_path: Path, config_package: str) -> None:
|
|
349
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
350
|
+
path = tmp_path / "config.toml"
|
|
351
|
+
path.write_text("original", encoding="utf-8")
|
|
352
|
+
|
|
353
|
+
failure = initialize_config(
|
|
354
|
+
"config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
|
|
355
|
+
).unwrap_err()[0]
|
|
356
|
+
|
|
357
|
+
assert isinstance(failure, errors.AlreadyExists)
|
|
358
|
+
assert failure.path == path
|
|
359
|
+
assert path.read_text(encoding="utf-8") == "original"
|
|
360
|
+
|
|
361
|
+
def test_missing_parent_is_not_created(self, tmp_path: Path, config_package: str) -> None:
|
|
362
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
363
|
+
cwd = tmp_path / "missing"
|
|
364
|
+
|
|
365
|
+
failure = initialize_config(
|
|
366
|
+
"config.toml", package=config_package, template="starter.toml", cwd=PathInput(cwd)
|
|
367
|
+
).unwrap_err()[0]
|
|
368
|
+
|
|
369
|
+
assert isinstance(failure, errors.Unwritable)
|
|
370
|
+
assert failure.path == cwd / "config.toml"
|
|
371
|
+
assert not cwd.exists()
|
|
372
|
+
|
|
373
|
+
def test_concurrent_creation_preserves_existing_config(
|
|
374
|
+
self, tmp_path: Path, config_package: str, mocker: MockerFixture
|
|
375
|
+
) -> None:
|
|
376
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
377
|
+
|
|
378
|
+
def create_concurrently(path: ProjectConfigPath, text: str) -> Result[None]:
|
|
379
|
+
path.write_text("original", encoding="utf-8")
|
|
380
|
+
return create_config(path, text)
|
|
381
|
+
|
|
382
|
+
mocker.patch("llm_tool_cli.config.files.create_config", side_effect=create_concurrently)
|
|
383
|
+
|
|
384
|
+
failure = initialize_config(
|
|
385
|
+
"config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
|
|
386
|
+
).unwrap_err()[0]
|
|
387
|
+
|
|
388
|
+
assert isinstance(failure, errors.AlreadyExists)
|
|
389
|
+
assert failure.path == tmp_path / "config.toml"
|
|
390
|
+
assert failure.path.read_text(encoding="utf-8") == "original"
|
|
391
|
+
|
|
392
|
+
def test_resolution_failure_stops_before_template_reading(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
393
|
+
failures: EnvironmentErrors = [
|
|
394
|
+
errors.PathResolutionFailed(path=tmp_path / name, reason="permission denied")
|
|
395
|
+
for name in ("first.toml", "second.toml")
|
|
396
|
+
]
|
|
397
|
+
mocker.patch("llm_tool_cli.config.files.resolve_init_config_path", return_value=Err(failures))
|
|
398
|
+
read_template = mocker.patch("llm_tool_cli.config.files.importlib.resources.files")
|
|
399
|
+
|
|
400
|
+
result = initialize_config("config.toml", package="unused", template="starter.toml", cwd=PathInput(tmp_path))
|
|
401
|
+
|
|
402
|
+
assert result.unwrap_err() == failures
|
|
403
|
+
read_template.assert_not_called()
|
|
404
|
+
|
|
405
|
+
@pytest.mark.parametrize("content", [None, b"\xff"])
|
|
406
|
+
def test_template_read_failure_preserves_cause(
|
|
407
|
+
self, tmp_path: Path, config_package: str, content: bytes | None
|
|
408
|
+
) -> None:
|
|
409
|
+
if content is not None:
|
|
410
|
+
(tmp_path / "fixtures/starter.toml").write_bytes(content)
|
|
411
|
+
|
|
412
|
+
failure = initialize_config(
|
|
413
|
+
"config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
|
|
414
|
+
).unwrap_err()[0]
|
|
415
|
+
|
|
416
|
+
assert isinstance(failure, errors.TemplateUnreadable)
|
|
417
|
+
assert isinstance(failure.cause, FileNotFoundError if content is None else UnicodeDecodeError)
|
|
418
|
+
assert failure.path == tmp_path / "config.toml"
|
|
419
|
+
assert failure.template == "starter.toml"
|
|
420
|
+
assert failure.reason == str(failure.cause)
|
|
421
|
+
assert not failure.path.exists()
|
|
422
|
+
|
|
423
|
+
def test_write_failures_propagate(self, tmp_path: Path, config_package: str, mocker: MockerFixture) -> None:
|
|
424
|
+
(tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
|
|
425
|
+
failures: EnvironmentErrors = [errors.Unwritable(path=tmp_path / "config.toml", reason="read-only filesystem")]
|
|
426
|
+
mocker.patch("llm_tool_cli.config.files.create_config", return_value=Err(failures))
|
|
427
|
+
|
|
428
|
+
result = initialize_config(
|
|
429
|
+
"config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
assert result.unwrap_err() == failures
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class TestLocateConfig:
|
|
436
|
+
def test_discovers_nearest_without_reading(self, tmp_path: Path) -> None:
|
|
437
|
+
nested = tmp_path / "nested"
|
|
438
|
+
nested.mkdir()
|
|
439
|
+
(tmp_path / "config.toml").touch()
|
|
440
|
+
nearest = nested / "config.toml"
|
|
441
|
+
nearest.write_text("invalid TOML [", encoding="utf-8")
|
|
442
|
+
|
|
443
|
+
assert locate_config("config.toml", cwd=nested).unwrap() == nearest
|
|
444
|
+
|
|
445
|
+
def test_discovers_in_parent(self, tmp_path: Path) -> None:
|
|
446
|
+
nested = tmp_path / "nested"
|
|
447
|
+
nested.mkdir()
|
|
448
|
+
config_path = tmp_path / "config.toml"
|
|
449
|
+
config_path.touch()
|
|
450
|
+
|
|
451
|
+
assert locate_config("config.toml", path=None, cwd=nested).unwrap() == config_path
|
|
452
|
+
|
|
453
|
+
def test_explicit_missing_path_does_not_fall_back(self, tmp_path: Path) -> None:
|
|
454
|
+
(tmp_path / "config.toml").touch()
|
|
455
|
+
|
|
456
|
+
assert (
|
|
457
|
+
locate_config("config.toml", path=Path("custom.toml"), cwd=tmp_path).unwrap() == tmp_path / "custom.toml"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def test_explicit_home_path(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
461
|
+
home = tmp_path / "home"
|
|
462
|
+
mocker.patch.dict("os.environ", {"HOME": str(home)})
|
|
463
|
+
|
|
464
|
+
assert locate_config("config.toml", path=Path("~/custom.toml"), cwd=tmp_path).unwrap() == home / "custom.toml"
|
|
465
|
+
|
|
466
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
467
|
+
def test_rejects_selected_symlink(self, tmp_path: Path, explicit: bool) -> None:
|
|
468
|
+
target = tmp_path / "target.toml"
|
|
469
|
+
target.touch()
|
|
470
|
+
nested = tmp_path / "nested"
|
|
471
|
+
nested.mkdir()
|
|
472
|
+
link = nested / "config.toml"
|
|
473
|
+
link.symlink_to(target)
|
|
474
|
+
|
|
475
|
+
result = locate_config("config.toml", cwd=nested, path=link if explicit else None)
|
|
476
|
+
|
|
477
|
+
assert result.unwrap_err() == [InvalidProjectPath(path=str(link))]
|
|
478
|
+
|
|
479
|
+
def test_missing(self, tmp_path: Path) -> None:
|
|
480
|
+
filename = f"{tmp_path.name}-missing.toml"
|
|
481
|
+
|
|
482
|
+
caught = locate_config(filename, cwd=tmp_path).unwrap_err()[0]
|
|
483
|
+
assert isinstance(caught, errors.NotFound)
|
|
484
|
+
|
|
485
|
+
assert caught.code == "config_not_found"
|
|
486
|
+
assert caught.path == tmp_path
|
|
487
|
+
assert filename in caught.reason
|
|
488
|
+
assert caught.cause is None
|
|
489
|
+
assert caught.as_record() == {
|
|
490
|
+
"type": "error",
|
|
491
|
+
"code": "config_not_found",
|
|
492
|
+
"message": caught.format_message(),
|
|
493
|
+
"path": str(tmp_path),
|
|
494
|
+
"reason": caught.reason,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
def test_discovery_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
498
|
+
mocker.patch.object(Path, "is_file", side_effect=PermissionError("access denied"))
|
|
499
|
+
|
|
500
|
+
caught = locate_config("config.toml", cwd=tmp_path).unwrap_err()[0]
|
|
501
|
+
assert isinstance(caught, errors.DiscoveryFailed)
|
|
502
|
+
|
|
503
|
+
assert caught.path == tmp_path / "config.toml"
|
|
504
|
+
assert isinstance(caught.cause, PermissionError)
|
|
505
|
+
|
|
506
|
+
def test_explicit_resolution_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
507
|
+
mocker.patch.object(Path, "resolve", side_effect=PermissionError("access denied"))
|
|
508
|
+
|
|
509
|
+
caught = locate_config("config.toml", path=Path("custom.toml"), cwd=tmp_path).unwrap_err()[0]
|
|
510
|
+
assert isinstance(caught, errors.PathResolutionFailed)
|
|
511
|
+
|
|
512
|
+
assert caught.path == tmp_path / "custom.toml"
|
|
513
|
+
assert isinstance(caught.cause, PermissionError)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class TestReadToml:
|
|
517
|
+
def test_toml_1_1_and_utf8(self, tmp_path: Path) -> None:
|
|
518
|
+
path = tmp_path / "config.toml"
|
|
519
|
+
path.write_text('item = {\n label = "café",\n enabled = true,\n}\n', encoding="utf-8")
|
|
520
|
+
|
|
521
|
+
assert read_toml(path).unwrap() == {"item": {"label": "café", "enabled": True}}
|
|
522
|
+
|
|
523
|
+
def test_empty(self, tmp_path: Path) -> None:
|
|
524
|
+
path = tmp_path / "config.toml"
|
|
525
|
+
path.touch()
|
|
526
|
+
|
|
527
|
+
assert read_toml(path).unwrap() == {}
|
|
528
|
+
|
|
529
|
+
def test_invalid_toml(self, tmp_path: Path) -> None:
|
|
530
|
+
path = tmp_path / "config.toml"
|
|
531
|
+
path.write_text("item = [", encoding="utf-8")
|
|
532
|
+
|
|
533
|
+
caught = read_toml(path).unwrap_err()[0]
|
|
534
|
+
assert isinstance(caught, errors.InvalidToml)
|
|
535
|
+
|
|
536
|
+
assert caught.code == "config_invalid_toml"
|
|
537
|
+
assert caught.path == path
|
|
538
|
+
assert isinstance(caught.cause, ValueError)
|
|
539
|
+
assert caught.reason == str(caught.cause)
|
|
540
|
+
|
|
541
|
+
def test_invalid_encoding(self, tmp_path: Path) -> None:
|
|
542
|
+
path = tmp_path / "config.toml"
|
|
543
|
+
path.write_bytes(b'label = "\xff"')
|
|
544
|
+
|
|
545
|
+
caught = read_toml(path).unwrap_err()[0]
|
|
546
|
+
assert isinstance(caught, errors.InvalidEncoding)
|
|
547
|
+
|
|
548
|
+
assert caught.code == "config_invalid_encoding"
|
|
549
|
+
assert caught.path == path
|
|
550
|
+
assert isinstance(caught.cause, UnicodeDecodeError)
|
|
551
|
+
|
|
552
|
+
def test_missing(self, tmp_path: Path) -> None:
|
|
553
|
+
path = tmp_path / "config.toml"
|
|
554
|
+
|
|
555
|
+
caught = read_toml(path).unwrap_err()[0]
|
|
556
|
+
assert isinstance(caught, errors.Unreadable)
|
|
557
|
+
|
|
558
|
+
assert caught.code == "config_unreadable"
|
|
559
|
+
assert caught.path == path
|
|
560
|
+
assert isinstance(caught.cause, FileNotFoundError)
|
|
561
|
+
assert caught.as_record() == {
|
|
562
|
+
"type": "error",
|
|
563
|
+
"code": "config_unreadable",
|
|
564
|
+
"message": caught.format_message(),
|
|
565
|
+
"path": str(path),
|
|
566
|
+
"reason": str(caught.cause),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
def test_directory(self, tmp_path: Path) -> None:
|
|
570
|
+
caught = read_toml(tmp_path).unwrap_err()[0]
|
|
571
|
+
assert isinstance(caught, errors.Unreadable)
|
|
572
|
+
|
|
573
|
+
assert caught.code == "config_unreadable"
|
|
574
|
+
assert caught.path == tmp_path
|
|
575
|
+
assert isinstance(caught.cause, IsADirectoryError)
|
|
576
|
+
|
|
577
|
+
def test_read_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
578
|
+
path = tmp_path / "config.toml"
|
|
579
|
+
path.touch()
|
|
580
|
+
mocker.patch("llm_tool_cli.config.files.tomli.load", side_effect=OSError("read failed"))
|
|
581
|
+
|
|
582
|
+
caught = read_toml(path).unwrap_err()[0]
|
|
583
|
+
assert isinstance(caught, errors.Unreadable)
|
|
584
|
+
|
|
585
|
+
assert caught.code == "config_unreadable"
|
|
586
|
+
assert caught.path == path
|
|
587
|
+
assert isinstance(caught.cause, OSError)
|
|
588
|
+
|
|
589
|
+
def test_unexpected_parser_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
590
|
+
path = tmp_path / "config.toml"
|
|
591
|
+
path.touch()
|
|
592
|
+
mocker.patch("llm_tool_cli.config.files.tomli.load", side_effect=AssertionError("parser defect"))
|
|
593
|
+
|
|
594
|
+
with pytest.raises(AssertionError):
|
|
595
|
+
read_toml(path)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class TestLoadConfig:
|
|
599
|
+
def test_supplied_model(self, tmp_path: Path) -> None:
|
|
600
|
+
class Config(pydantic.BaseModel):
|
|
601
|
+
count: int
|
|
602
|
+
label: str = "default"
|
|
603
|
+
|
|
604
|
+
path = tmp_path / "custom.toml"
|
|
605
|
+
path.write_text('count = "3"\n', encoding="utf-8")
|
|
606
|
+
|
|
607
|
+
loaded: Config = load_config(path, Config).unwrap()
|
|
608
|
+
|
|
609
|
+
assert loaded == Config(count=3)
|
|
610
|
+
|
|
611
|
+
def test_empty_file_uses_model_defaults(self, tmp_path: Path) -> None:
|
|
612
|
+
class Config(pydantic.BaseModel):
|
|
613
|
+
enabled: bool = True
|
|
614
|
+
|
|
615
|
+
path = tmp_path / "custom.toml"
|
|
616
|
+
path.touch()
|
|
617
|
+
|
|
618
|
+
assert load_config(path, Config).unwrap() == Config()
|
|
619
|
+
|
|
620
|
+
@pytest.mark.parametrize("text", ["", 'count = "invalid"\n', "count = -1\n"])
|
|
621
|
+
def test_validation_failure(self, tmp_path: Path, text: str) -> None:
|
|
622
|
+
class Config(pydantic.BaseModel):
|
|
623
|
+
count: int = pydantic.Field(gt=0)
|
|
624
|
+
|
|
625
|
+
path = Path("custom.toml")
|
|
626
|
+
(tmp_path / path).write_text(text, encoding="utf-8")
|
|
627
|
+
|
|
628
|
+
with contextlib.chdir(tmp_path):
|
|
629
|
+
caught = load_config(path, Config).unwrap_err()[0]
|
|
630
|
+
|
|
631
|
+
assert isinstance(caught, errors.ValidationFailed)
|
|
632
|
+
|
|
633
|
+
assert caught.code == "config_validation_failed"
|
|
634
|
+
assert caught.path == path
|
|
635
|
+
assert isinstance(caught.cause, pydantic.ValidationError)
|
|
636
|
+
assert caught.reason == str(caught.cause)
|
|
637
|
+
assert caught.as_record() == {
|
|
638
|
+
"type": "error",
|
|
639
|
+
"code": "config_validation_failed",
|
|
640
|
+
"message": caught.format_message(),
|
|
641
|
+
"path": str(path),
|
|
642
|
+
"reason": str(caught.cause),
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
@pytest.mark.parametrize(
|
|
646
|
+
("data", "expected_error"),
|
|
647
|
+
[(None, errors.Unreadable), (b"item = [", errors.InvalidToml), (b'label = "\xff"', errors.InvalidEncoding)],
|
|
648
|
+
)
|
|
649
|
+
def test_read_failure(
|
|
650
|
+
self, tmp_path: Path, data: bytes | None, expected_error: type[errors.EnvironmentError]
|
|
651
|
+
) -> None:
|
|
652
|
+
path = tmp_path / "custom.toml"
|
|
653
|
+
if data is not None:
|
|
654
|
+
path.write_bytes(data)
|
|
655
|
+
|
|
656
|
+
caught = load_config(path, pydantic.BaseModel).unwrap_err()[0]
|
|
657
|
+
assert isinstance(caught, expected_error)
|
|
658
|
+
|
|
659
|
+
assert caught.path == path
|
|
660
|
+
assert caught.cause is not None
|
|
661
|
+
|
|
662
|
+
def test_unexpected_model_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
663
|
+
path = tmp_path / "custom.toml"
|
|
664
|
+
path.touch()
|
|
665
|
+
mocker.patch.object(pydantic.BaseModel, "model_validate", side_effect=TypeError("model defect"))
|
|
666
|
+
|
|
667
|
+
with pytest.raises(TypeError):
|
|
668
|
+
load_config(path, pydantic.BaseModel)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
class TestCreateConfig:
|
|
672
|
+
@pytest.mark.parametrize("text", ["", '# comment\r\nlabel = "café"\r\n'])
|
|
673
|
+
def test_preserves_text(self, tmp_path: Path, text: str) -> None:
|
|
674
|
+
path = ProjectConfigPath(tmp_path / "config.toml")
|
|
675
|
+
|
|
676
|
+
assert create_config(path, text).unwrap() is None
|
|
677
|
+
|
|
678
|
+
assert path.read_bytes() == text.encode("utf-8")
|
|
679
|
+
|
|
680
|
+
def test_refuses_overwrite(self, tmp_path: Path) -> None:
|
|
681
|
+
path = ProjectConfigPath(tmp_path / "config.toml")
|
|
682
|
+
create_config(path, "original").unwrap()
|
|
683
|
+
|
|
684
|
+
caught = create_config(path, "replacement").unwrap_err()[0]
|
|
685
|
+
assert isinstance(caught, errors.AlreadyExists)
|
|
686
|
+
|
|
687
|
+
assert caught.code == "config_already_exists"
|
|
688
|
+
assert caught.path == path
|
|
689
|
+
assert isinstance(caught.cause, FileExistsError)
|
|
690
|
+
assert path.read_text(encoding="utf-8") == "original"
|
|
691
|
+
|
|
692
|
+
def test_existing_directory(self, tmp_path: Path) -> None:
|
|
693
|
+
caught = create_config(ProjectConfigPath(tmp_path), "text").unwrap_err()[0]
|
|
694
|
+
assert isinstance(caught, errors.AlreadyExists)
|
|
695
|
+
|
|
696
|
+
assert caught.code == "config_already_exists"
|
|
697
|
+
assert caught.path == tmp_path
|
|
698
|
+
assert tmp_path.is_dir()
|
|
699
|
+
|
|
700
|
+
@pytest.mark.parametrize("target_exists", [False, True])
|
|
701
|
+
def test_existing_symlink(self, tmp_path: Path, target_exists: bool) -> None:
|
|
702
|
+
target = tmp_path / "target.toml"
|
|
703
|
+
if target_exists:
|
|
704
|
+
target.write_text("original", encoding="utf-8")
|
|
705
|
+
path = ProjectConfigPath(tmp_path / "config.toml")
|
|
706
|
+
path.symlink_to(target)
|
|
707
|
+
|
|
708
|
+
caught = create_config(path, "replacement").unwrap_err()[0]
|
|
709
|
+
assert isinstance(caught, errors.AlreadyExists)
|
|
710
|
+
|
|
711
|
+
assert caught.code == "config_already_exists"
|
|
712
|
+
assert path.is_symlink()
|
|
713
|
+
if target_exists:
|
|
714
|
+
assert target.read_text(encoding="utf-8") == "original"
|
|
715
|
+
else:
|
|
716
|
+
assert not target.exists()
|
|
717
|
+
|
|
718
|
+
def test_missing_parent(self, tmp_path: Path) -> None:
|
|
719
|
+
path = ProjectConfigPath(tmp_path / "missing/config.toml")
|
|
720
|
+
|
|
721
|
+
caught = create_config(path, "text").unwrap_err()[0]
|
|
722
|
+
assert isinstance(caught, errors.Unwritable)
|
|
723
|
+
|
|
724
|
+
assert caught.code == "config_unwritable"
|
|
725
|
+
assert caught.path == path
|
|
726
|
+
assert isinstance(caught.cause, FileNotFoundError)
|
|
727
|
+
assert not path.parent.exists()
|
|
728
|
+
|
|
729
|
+
def test_invalid_text(self, tmp_path: Path) -> None:
|
|
730
|
+
path = ProjectConfigPath(tmp_path / "config.toml")
|
|
731
|
+
|
|
732
|
+
caught = create_config(path, "\ud800").unwrap_err()[0]
|
|
733
|
+
assert isinstance(caught, errors.Unwritable)
|
|
734
|
+
|
|
735
|
+
assert caught.code == "config_unwritable"
|
|
736
|
+
assert caught.path == path
|
|
737
|
+
assert isinstance(caught.cause, UnicodeEncodeError)
|
|
738
|
+
assert not path.exists()
|
|
739
|
+
|
|
740
|
+
def test_write_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
741
|
+
path = ProjectConfigPath(tmp_path / "config.toml")
|
|
742
|
+
stream = mocker.patch.object(Path, "open").return_value.__enter__.return_value
|
|
743
|
+
stream.write.side_effect = OSError("write failed")
|
|
744
|
+
|
|
745
|
+
caught = create_config(path, "text").unwrap_err()[0]
|
|
746
|
+
assert isinstance(caught, errors.Unwritable)
|
|
747
|
+
|
|
748
|
+
assert caught.code == "config_unwritable"
|
|
749
|
+
assert caught.path == path
|
|
750
|
+
assert isinstance(caught.cause, OSError)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class TestCreateConfigFromTemplate:
|
|
754
|
+
@pytest.mark.parametrize(
|
|
755
|
+
("template", "text"),
|
|
756
|
+
[("empty.toml", ""), ("custom.toml", ' # Привет 🌍\nlabel = "café"\n\n'), ("invalid.toml", "item = [")],
|
|
757
|
+
)
|
|
758
|
+
def test_creates_from_selected_package_and_template(
|
|
759
|
+
self, tmp_path: Path, config_package: str, template: str, text: str
|
|
760
|
+
) -> None:
|
|
761
|
+
(tmp_path / "fixtures" / template).write_text(text, encoding="utf-8")
|
|
762
|
+
path = ProjectConfigPath(Path("custom.toml"))
|
|
763
|
+
|
|
764
|
+
with contextlib.chdir(tmp_path):
|
|
765
|
+
assert create_config_from_template(path, package=config_package, template=template).unwrap() is None
|
|
766
|
+
|
|
767
|
+
assert (tmp_path / path).read_bytes() == text.encode("utf-8")
|
|
768
|
+
|
|
769
|
+
@pytest.mark.parametrize("content", [None, b"\xff"])
|
|
770
|
+
@pytest.mark.parametrize("target_exists", [False, True])
|
|
771
|
+
def test_template_failure_precedes_creation_and_preserves_target(
|
|
772
|
+
self, tmp_path: Path, config_package: str, content: bytes | None, target_exists: bool
|
|
773
|
+
) -> None:
|
|
774
|
+
if content is not None:
|
|
775
|
+
(tmp_path / "fixtures" / "base.toml").write_bytes(content)
|
|
776
|
+
path = ProjectConfigPath(tmp_path / "custom.toml")
|
|
777
|
+
if target_exists:
|
|
778
|
+
path.write_text("original", encoding="utf-8")
|
|
779
|
+
|
|
780
|
+
failures = create_config_from_template(path, package=config_package, template="base.toml").unwrap_err()
|
|
781
|
+
|
|
782
|
+
assert len(failures) == 1
|
|
783
|
+
failure = failures[0]
|
|
784
|
+
assert isinstance(failure, errors.TemplateUnreadable)
|
|
785
|
+
assert isinstance(failure.cause, FileNotFoundError if content is None else UnicodeDecodeError)
|
|
786
|
+
assert failure.as_record() == {
|
|
787
|
+
"type": "error",
|
|
788
|
+
"code": "config_template_unreadable",
|
|
789
|
+
"message": failure.format_message(),
|
|
790
|
+
"path": str(path),
|
|
791
|
+
"template": "base.toml",
|
|
792
|
+
"reason": str(failure.cause),
|
|
793
|
+
}
|
|
794
|
+
if target_exists:
|
|
795
|
+
assert path.read_text(encoding="utf-8") == "original"
|
|
796
|
+
else:
|
|
797
|
+
assert not path.exists()
|
|
798
|
+
|
|
799
|
+
@pytest.mark.parametrize("target_exists", [False, True])
|
|
800
|
+
def test_creation_failure_keeps_write_diagnostic(
|
|
801
|
+
self, tmp_path: Path, config_package: str, target_exists: bool
|
|
802
|
+
) -> None:
|
|
803
|
+
(tmp_path / "fixtures" / "base.toml").write_text("replacement", encoding="utf-8")
|
|
804
|
+
path = ProjectConfigPath(tmp_path / "target" / "config.toml")
|
|
805
|
+
if target_exists:
|
|
806
|
+
path.parent.mkdir()
|
|
807
|
+
path.write_text("original", encoding="utf-8")
|
|
808
|
+
|
|
809
|
+
failure = create_config_from_template(path, package=config_package, template="base.toml").unwrap_err()[0]
|
|
810
|
+
|
|
811
|
+
if target_exists:
|
|
812
|
+
assert isinstance(failure, errors.AlreadyExists)
|
|
813
|
+
assert path.read_text(encoding="utf-8") == "original"
|
|
814
|
+
else:
|
|
815
|
+
assert isinstance(failure, errors.Unwritable)
|
|
816
|
+
assert not path.parent.exists()
|
|
817
|
+
assert failure.path == path
|
|
818
|
+
assert isinstance(failure.cause, FileExistsError if target_exists else FileNotFoundError)
|
|
819
|
+
|
|
820
|
+
def test_unexpected_resource_failure_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
821
|
+
mocker.patch("llm_tool_cli.config.files.importlib.resources.files", side_effect=RuntimeError("broken loader"))
|
|
822
|
+
|
|
823
|
+
with pytest.raises(RuntimeError, match="broken loader"):
|
|
824
|
+
create_config_from_template(
|
|
825
|
+
ProjectConfigPath(tmp_path / "config.toml"), package="config_fixture_package", template="base.toml"
|
|
826
|
+
)
|