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,125 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import typer
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.cli.options import ConfigOption, ProtocolOption
|
|
8
|
+
from llm_tool_cli.core import settings
|
|
9
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
10
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
11
|
+
from llm_tool_cli.protocol import Protocol
|
|
12
|
+
|
|
13
|
+
__all__ = ["isolated_settings"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def protocol_app(isolated_settings: None) -> typer.Typer:
|
|
18
|
+
settings.initialize(label=settings.ToolLabel("TEST"))
|
|
19
|
+
app = typer.Typer()
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def initialize(protocol: ProtocolOption = None) -> None:
|
|
23
|
+
typer.echo("unspecified" if protocol is None else protocol.value)
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def show() -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
return app
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestParseConfigPath:
|
|
33
|
+
@pytest.mark.parametrize(
|
|
34
|
+
("value", "expected"),
|
|
35
|
+
[
|
|
36
|
+
(None, None),
|
|
37
|
+
("", "."),
|
|
38
|
+
(".", "."),
|
|
39
|
+
("missing/config.toml", "missing/config.toml"),
|
|
40
|
+
("/absolute/config.toml", "/absolute/config.toml"),
|
|
41
|
+
("relative/../config.toml", "relative/../config.toml"),
|
|
42
|
+
("~/config.toml", "~/config.toml"),
|
|
43
|
+
(" café 日本語.toml ", " café 日本語.toml "),
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
def test_parses_without_filesystem_validation(
|
|
47
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str | None, expected: str | None
|
|
48
|
+
) -> None:
|
|
49
|
+
monkeypatch.chdir(tmp_path)
|
|
50
|
+
app = typer.Typer()
|
|
51
|
+
received: list[ProjectConfigPath | None] = []
|
|
52
|
+
|
|
53
|
+
@app.callback()
|
|
54
|
+
def initialize(config: ConfigOption = None) -> None:
|
|
55
|
+
received.append(config)
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def show() -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
arguments = [] if value is None else ["--config", value]
|
|
62
|
+
result = CliRunner().invoke(app, [*arguments, "show"])
|
|
63
|
+
|
|
64
|
+
assert result.exit_code == 0
|
|
65
|
+
assert not result.output
|
|
66
|
+
assert received == [None if expected is None else ProjectConfigPath(Path(expected))]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestParseProtocol:
|
|
70
|
+
@pytest.mark.parametrize("option", ["-p", "--protocol"])
|
|
71
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
72
|
+
def test_parses_supported_values(self, protocol_app: typer.Typer, option: str, protocol: Protocol) -> None:
|
|
73
|
+
result = CliRunner().invoke(protocol_app, [option, protocol.value, "show"])
|
|
74
|
+
|
|
75
|
+
assert result.exit_code == 0
|
|
76
|
+
assert result.stdout == f"{protocol.value}\n"
|
|
77
|
+
assert not result.stderr
|
|
78
|
+
|
|
79
|
+
def test_leaves_missing_protocol_unspecified(self, protocol_app: typer.Typer) -> None:
|
|
80
|
+
result = CliRunner().invoke(protocol_app, ["show"])
|
|
81
|
+
|
|
82
|
+
assert result.exit_code == 0
|
|
83
|
+
assert result.stdout == "unspecified\n"
|
|
84
|
+
assert not result.stderr
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("value", ["invalid", "HUMAN", " llm", "llm ", "", "{protocol}", "{"])
|
|
87
|
+
@pytest.mark.parametrize("prefix", [[], ["-p", "automation"]])
|
|
88
|
+
def test_invalid_value_uses_llm_cell_before_command_execution(
|
|
89
|
+
self, protocol_app: typer.Typer, value: str, prefix: list[str]
|
|
90
|
+
) -> None:
|
|
91
|
+
result = CliRunner().invoke(protocol_app, [*prefix, "--protocol", value, "show"])
|
|
92
|
+
|
|
93
|
+
assert result.exit_code == 1
|
|
94
|
+
assert not result.stderr
|
|
95
|
+
assert result.stdout.startswith("--TEST-CELL ")
|
|
96
|
+
assert result.stdout.count(" BEGIN--\n") == 1
|
|
97
|
+
assert result.stdout.count(" END--\n") == 1
|
|
98
|
+
assert "kind=error\n" in result.stdout
|
|
99
|
+
assert "code=invalid_arguments\n" in result.stdout
|
|
100
|
+
assert "type=error\n" in result.stdout
|
|
101
|
+
assert "\nreason=" in result.stdout
|
|
102
|
+
assert f"`{value}`" in result.stdout
|
|
103
|
+
for protocol in Protocol:
|
|
104
|
+
assert protocol.value in result.stdout
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize("option", ["-p", "--protocol"])
|
|
107
|
+
def test_missing_value_remains_a_framework_error(self, protocol_app: typer.Typer, option: str) -> None:
|
|
108
|
+
result = CliRunner().invoke(protocol_app, [option])
|
|
109
|
+
|
|
110
|
+
assert result.exit_code == 2
|
|
111
|
+
assert not result.stdout
|
|
112
|
+
assert option in result.stderr
|
|
113
|
+
assert "--TEST-CELL " not in result.stderr
|
|
114
|
+
|
|
115
|
+
def test_help_describes_shared_option(self, protocol_app: typer.Typer) -> None:
|
|
116
|
+
result = CliRunner().invoke(protocol_app, ["--help"])
|
|
117
|
+
|
|
118
|
+
assert result.exit_code == 0
|
|
119
|
+
assert not result.stderr
|
|
120
|
+
assert "--protocol" in result.stdout
|
|
121
|
+
assert "-p" in result.stdout
|
|
122
|
+
assert "PROTOCOL" in result.stdout
|
|
123
|
+
for protocol in Protocol:
|
|
124
|
+
assert protocol.value in result.stdout
|
|
125
|
+
assert "skill" in result.stdout
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Configuration file mechanics and reusable schema fields.
|
|
2
|
+
|
|
3
|
+
The exported operations, ``config.fields``, and ``config.errors`` are public interfaces.
|
|
4
|
+
Applications declare their default filename in static settings and own template
|
|
5
|
+
resources, schemas, workspace construction, and presentation of failures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.config.files import (
|
|
9
|
+
create_config,
|
|
10
|
+
create_config_from_template,
|
|
11
|
+
find_config,
|
|
12
|
+
initialize_config,
|
|
13
|
+
load_config,
|
|
14
|
+
locate_config,
|
|
15
|
+
read_toml,
|
|
16
|
+
resolve_config_path,
|
|
17
|
+
resolve_init_config_path,
|
|
18
|
+
)
|
|
19
|
+
from llm_tool_cli.config.project import initialize_project, load_project
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"create_config",
|
|
23
|
+
"create_config_from_template",
|
|
24
|
+
"find_config",
|
|
25
|
+
"initialize_config",
|
|
26
|
+
"initialize_project",
|
|
27
|
+
"load_config",
|
|
28
|
+
"load_project",
|
|
29
|
+
"locate_config",
|
|
30
|
+
"read_toml",
|
|
31
|
+
"resolve_config_path",
|
|
32
|
+
"resolve_init_config_path",
|
|
33
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Public configuration failures.
|
|
2
|
+
|
|
3
|
+
Concrete ``code`` values are stable compatibility identifiers. Callers may inspect
|
|
4
|
+
the returned values without parsing messages. ``path`` identifies the
|
|
5
|
+
failed operation's filesystem input; ``reason`` contains the original reason.
|
|
6
|
+
The producing operation preserves any underlying low-level exception as ``cause``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import ClassVar
|
|
11
|
+
|
|
12
|
+
from llm_tool_cli.core import errors as core_errors
|
|
13
|
+
from llm_tool_cli.core.entities import ExitCode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InternalError(core_errors.InternalError):
|
|
17
|
+
"""Misuse of configuration setup interfaces."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigFilenameNotSet(InternalError):
|
|
21
|
+
message_template = "Project setup requires a configuration filename in static settings."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EnvironmentError(core_errors.EnvironmentError):
|
|
25
|
+
"""Classification and filesystem context for configuration failures."""
|
|
26
|
+
|
|
27
|
+
cli_exit_code: ClassVar[ExitCode] = ExitCode.configuration_error
|
|
28
|
+
path: Path
|
|
29
|
+
reason: str
|
|
30
|
+
message: str = "{error.path}: {error.reason}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PathResolutionFailed(EnvironmentError):
|
|
34
|
+
"""An explicit path could not be resolved."""
|
|
35
|
+
|
|
36
|
+
code: str = "config_path_resolution_failed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DiscoveryFailed(EnvironmentError):
|
|
40
|
+
"""The search directory or a candidate could not be inspected."""
|
|
41
|
+
|
|
42
|
+
code: str = "config_discovery_failed"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotFound(EnvironmentError):
|
|
46
|
+
"""No configuration file was found in the search directory or its parents."""
|
|
47
|
+
|
|
48
|
+
code: str = "config_not_found"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Unreadable(EnvironmentError):
|
|
52
|
+
"""A configuration file could not be opened or read."""
|
|
53
|
+
|
|
54
|
+
code: str = "config_unreadable"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InvalidEncoding(EnvironmentError):
|
|
58
|
+
"""A configuration file does not contain valid UTF-8."""
|
|
59
|
+
|
|
60
|
+
code: str = "config_invalid_encoding"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InvalidToml(EnvironmentError):
|
|
64
|
+
"""A configuration file does not contain valid TOML."""
|
|
65
|
+
|
|
66
|
+
code: str = "config_invalid_toml"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ValidationFailed(EnvironmentError):
|
|
70
|
+
"""Parsed configuration does not satisfy the supplied model."""
|
|
71
|
+
|
|
72
|
+
code: str = "config_validation_failed"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AlreadyExists(EnvironmentError):
|
|
76
|
+
"""The starter target already exists and was left untouched."""
|
|
77
|
+
|
|
78
|
+
code: str = "config_already_exists"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TemplateUnreadable(EnvironmentError):
|
|
82
|
+
"""A packaged starter could not be read as UTF-8 for the target path."""
|
|
83
|
+
|
|
84
|
+
code: str = "config_template_unreadable"
|
|
85
|
+
message: str = "could not read configuration template `{error.template}`: {error.reason}"
|
|
86
|
+
template: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Unwritable(EnvironmentError):
|
|
90
|
+
"""Starter text could not be encoded or written to the target."""
|
|
91
|
+
|
|
92
|
+
code: str = "config_unwritable"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Reusable fields for application-owned configuration schemas."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.paths import RelativeProjectPath
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _validate_config_version(value: object, handler: pydantic.ValidatorFunctionWrapHandler) -> object:
|
|
11
|
+
"""Require an integer and explain failures of the application's version constraint."""
|
|
12
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
13
|
+
raise ValueError(f"Configuration version must be an integer; received {value!r}.")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
return handler(value)
|
|
17
|
+
except pydantic.ValidationError as error:
|
|
18
|
+
failures = error.errors()
|
|
19
|
+
if len(failures) != 1 or failures[0]["type"] != "literal_error":
|
|
20
|
+
raise
|
|
21
|
+
supported = failures[0]["ctx"]["expected"]
|
|
22
|
+
raise ValueError(f"Unsupported configuration version {value}; supported version: {supported}.") from error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
type ConfigVersion[Version: int] = Annotated[Version, pydantic.WrapValidator(_validate_config_version)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_relative_project_path(path: RelativeProjectPath) -> RelativeProjectPath:
|
|
29
|
+
"""Pydantic validator for project-relative paths without parent references."""
|
|
30
|
+
if path.is_absolute():
|
|
31
|
+
raise ValueError("Project paths must be relative to the project root.")
|
|
32
|
+
|
|
33
|
+
if any(part == ".." for part in path.parts):
|
|
34
|
+
raise ValueError("Project paths must not contain parent-directory references.")
|
|
35
|
+
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
type SessionDir = Annotated[RelativeProjectPath, pydantic.AfterValidator(validate_relative_project_path)]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
import tomli
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.config import errors
|
|
8
|
+
from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
|
|
9
|
+
from llm_tool_cli.paths import PathInput, ProjectConfigPath, ProjectRootPath, resolve_inside_project
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@unwrap_to_error
|
|
13
|
+
def find_config(filename: str, start_dir: Path) -> Result[ProjectConfigPath | None]:
|
|
14
|
+
"""Find the nearest file from the resolved start directory through its root.
|
|
15
|
+
|
|
16
|
+
Matching directories are skipped. No match returns ``Ok(None)``; filesystem
|
|
17
|
+
failures reported by pathlib return ``Err([DiscoveryFailed(...)])``.
|
|
18
|
+
Shared path validation rejects matching symlinks, including broken links,
|
|
19
|
+
before checking file existence. No matching file is read.
|
|
20
|
+
"""
|
|
21
|
+
candidate = start_dir
|
|
22
|
+
try:
|
|
23
|
+
current = start_dir.resolve()
|
|
24
|
+
for directory in (current, *current.parents):
|
|
25
|
+
candidate = directory / filename
|
|
26
|
+
resolve_inside_project(candidate, ProjectRootPath(directory)).unwrap()
|
|
27
|
+
if candidate.is_file():
|
|
28
|
+
return Ok(ProjectConfigPath(candidate))
|
|
29
|
+
except (OSError, RuntimeError) as exc:
|
|
30
|
+
return Err([errors.DiscoveryFailed(path=candidate, reason=str(exc)).with_cause(exc)])
|
|
31
|
+
return Ok(None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@unwrap_to_error
|
|
35
|
+
def resolve_config_path(path: Path, cwd: Path) -> Result[ProjectConfigPath]:
|
|
36
|
+
"""Expand an explicit path's home marker, then resolve it against cwd.
|
|
37
|
+
|
|
38
|
+
Resolve the containing directory as the project root, then apply shared
|
|
39
|
+
containment and symlink validation to the file. Neither needs to exist.
|
|
40
|
+
Do not search for a config or interpret application path syntax. Home
|
|
41
|
+
expansion and directory resolution failures return ``PathResolutionFailed``;
|
|
42
|
+
shared path diagnostics propagate unchanged.
|
|
43
|
+
"""
|
|
44
|
+
candidate = path
|
|
45
|
+
try:
|
|
46
|
+
expanded = path.expanduser()
|
|
47
|
+
candidate = expanded if expanded.is_absolute() else cwd / expanded
|
|
48
|
+
root = ProjectRootPath(candidate.parent.resolve())
|
|
49
|
+
return Ok(ProjectConfigPath(resolve_inside_project(root / candidate.name, root).unwrap()))
|
|
50
|
+
except (OSError, RuntimeError) as exc:
|
|
51
|
+
return Err([errors.PathResolutionFailed(path=candidate, reason=str(exc)).with_cause(exc)])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_init_config_path(
|
|
55
|
+
filename: str, *, path: ProjectConfigPath | None = None, cwd: PathInput
|
|
56
|
+
) -> Result[ProjectConfigPath]:
|
|
57
|
+
"""Select and resolve a configuration target without upward discovery.
|
|
58
|
+
|
|
59
|
+
An explicit path takes precedence over the filename under ``cwd``.
|
|
60
|
+
Reuse ``resolve_config_path`` for home expansion, shared path validation, and
|
|
61
|
+
unchanged failure propagation. No file is read or created, and neither the
|
|
62
|
+
target nor its parent is required to exist.
|
|
63
|
+
"""
|
|
64
|
+
return resolve_config_path(path if path is not None else Path(filename), cwd)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@unwrap_to_error
|
|
68
|
+
def locate_config(filename: str, *, path: Path | None = None, cwd: Path) -> Result[ProjectConfigPath]:
|
|
69
|
+
"""Select an explicit configuration path or discover the nearest file.
|
|
70
|
+
|
|
71
|
+
Explicit paths use ``resolve_config_path`` without falling back to discovery
|
|
72
|
+
or requiring an existing target. Otherwise, search with ``find_config`` and
|
|
73
|
+
return ``Err([NotFound(...)])`` if no match exists. Its ``path`` is the supplied search
|
|
74
|
+
directory and its ``reason`` identifies the filename and search scope.
|
|
75
|
+
Resolution and discovery failures propagate unchanged. No file is read.
|
|
76
|
+
"""
|
|
77
|
+
if path is not None:
|
|
78
|
+
return resolve_config_path(path, cwd)
|
|
79
|
+
|
|
80
|
+
config_path = find_config(filename, cwd).unwrap()
|
|
81
|
+
if config_path is None:
|
|
82
|
+
return Err([errors.NotFound(path=cwd, reason=f"{filename} was not found in this directory or its parents")])
|
|
83
|
+
return Ok(config_path)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_toml(path: Path) -> Result[dict[str, object]]:
|
|
87
|
+
"""Read UTF-8 TOML 1.1 data without applying an application schema.
|
|
88
|
+
|
|
89
|
+
Filesystem, UTF-8, and TOML failures return ``Unreadable``, ``InvalidEncoding``,
|
|
90
|
+
and ``InvalidToml`` in error lists respectively. The supplied path is used without discovery
|
|
91
|
+
or normalization. An empty document returns ``Ok({})``.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
with path.open("rb") as stream:
|
|
95
|
+
return Ok(tomli.load(stream))
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
return Err([errors.Unreadable(path=path, reason=str(exc)).with_cause(exc)])
|
|
98
|
+
except UnicodeDecodeError as exc:
|
|
99
|
+
return Err([errors.InvalidEncoding(path=path, reason=str(exc)).with_cause(exc)])
|
|
100
|
+
except tomli.TOMLDecodeError as exc:
|
|
101
|
+
return Err([errors.InvalidToml(path=path, reason=str(exc)).with_cause(exc)])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@unwrap_to_error
|
|
105
|
+
def load_config[T: pydantic.BaseModel](path: Path, config_class: type[T]) -> Result[T]:
|
|
106
|
+
"""Read a TOML file and validate it using the caller's Pydantic model.
|
|
107
|
+
|
|
108
|
+
Use the supplied path unchanged, without discovery or normalization.
|
|
109
|
+
The model owns its schema, defaults, and validation rules. Read failures
|
|
110
|
+
propagate as documented by ``read_toml``; Pydantic validation failures return
|
|
111
|
+
``Err([ValidationFailed(...)])`` with diagnostic text and the original exception cause.
|
|
112
|
+
Unexpected model failures propagate unchanged.
|
|
113
|
+
"""
|
|
114
|
+
raw = read_toml(path).unwrap()
|
|
115
|
+
try:
|
|
116
|
+
return Ok(config_class.model_validate(raw))
|
|
117
|
+
except pydantic.ValidationError as exc:
|
|
118
|
+
return Err([errors.ValidationFailed(path=path, reason=str(exc)).with_cause(exc)])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_config(path: ProjectConfigPath, text: str) -> Result[None]:
|
|
122
|
+
"""Create a starter from verbatim UTF-8 text, without overwriting any target.
|
|
123
|
+
|
|
124
|
+
The parent must already exist. No discovery, path normalization, directory
|
|
125
|
+
creation, or TOML validation occurs. Exclusive creation returns ``AlreadyExists``
|
|
126
|
+
for an existing target, including a symlink. Encoding and other filesystem
|
|
127
|
+
failures return ``Unwritable``. Failures are carried in error lists; success is
|
|
128
|
+
``Ok(None)``. Failed writes may leave a partial new file.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
data = text.encode("utf-8")
|
|
132
|
+
with path.open("xb") as stream:
|
|
133
|
+
stream.write(data)
|
|
134
|
+
except FileExistsError as exc:
|
|
135
|
+
return Err([errors.AlreadyExists(path=path, reason=str(exc)).with_cause(exc)])
|
|
136
|
+
except (OSError, UnicodeEncodeError) as exc:
|
|
137
|
+
return Err([errors.Unwritable(path=path, reason=str(exc)).with_cause(exc)])
|
|
138
|
+
return Ok(None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_config_from_template(path: ProjectConfigPath, *, package: str, template: str) -> Result[None]:
|
|
142
|
+
"""Read a packaged UTF-8 starter and create it at the supplied target path.
|
|
143
|
+
|
|
144
|
+
Read ``fixtures/<template>`` from ``package`` before attempting creation.
|
|
145
|
+
Filesystem and decoding failures return ``Err([TemplateUnreadable(...)])``
|
|
146
|
+
with the target path, template name, reason, and private original cause.
|
|
147
|
+
Unexpected resource errors propagate. Otherwise, pass the loaded text to
|
|
148
|
+
``create_config`` and return its result unchanged, including write failures.
|
|
149
|
+
No target selection, path resolution, or configuration validation occurs.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
text = importlib.resources.files(package).joinpath("fixtures", template).read_text(encoding="utf-8")
|
|
153
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
154
|
+
return Err([errors.TemplateUnreadable(path=path, template=template, reason=str(exc)).with_cause(exc)])
|
|
155
|
+
return create_config(path, text)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@unwrap_to_error
|
|
159
|
+
def initialize_config(
|
|
160
|
+
filename: str,
|
|
161
|
+
*,
|
|
162
|
+
package: str,
|
|
163
|
+
template: str,
|
|
164
|
+
cwd: PathInput,
|
|
165
|
+
path: ProjectConfigPath | None = None,
|
|
166
|
+
) -> Result[ProjectConfigPath]:
|
|
167
|
+
"""Resolve an initialization target and create the selected packaged starter.
|
|
168
|
+
|
|
169
|
+
Return the resolved path only after successful creation. Resolution, template
|
|
170
|
+
reading, and creation failures propagate unchanged, stopping the sequence.
|
|
171
|
+
No configuration loading, schema validation, or workspace construction occurs.
|
|
172
|
+
"""
|
|
173
|
+
selected_path = resolve_init_config_path(filename, path=path, cwd=cwd).unwrap()
|
|
174
|
+
create_config_from_template(selected_path, package=package, template=template).unwrap()
|
|
175
|
+
return Ok(selected_path)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Project preparation and scoped settings, independent of CLI frameworks."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.config import errors
|
|
9
|
+
from llm_tool_cli.config.files import initialize_config, load_config, locate_config
|
|
10
|
+
from llm_tool_cli.core import settings
|
|
11
|
+
from llm_tool_cli.core.context import ContextValue
|
|
12
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
13
|
+
from llm_tool_cli.core.result import Err, Result
|
|
14
|
+
from llm_tool_cli.paths import PathInput, ProjectConfigPath, ProjectRootPath
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectConfig(BaseEntity):
|
|
18
|
+
config_path: ProjectConfigPath
|
|
19
|
+
|
|
20
|
+
@pydantic.field_validator("config_path")
|
|
21
|
+
@classmethod
|
|
22
|
+
def validate_config_path(cls, value: ProjectConfigPath) -> ProjectConfigPath:
|
|
23
|
+
if not value.is_absolute():
|
|
24
|
+
raise ValueError("The active configuration path must be absolute.")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def project_dir(self) -> ProjectRootPath:
|
|
29
|
+
return ProjectRootPath(self.config_path.parent)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
project_config = ContextValue[ProjectConfig]("project_config")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def load_project[T: pydantic.BaseModel](
|
|
37
|
+
config_class: type[T], *, cwd: PathInput, path: ProjectConfigPath | None = None
|
|
38
|
+
) -> Iterator[Result[T]]:
|
|
39
|
+
"""Select and validate a configuration, binding its project for the scope.
|
|
40
|
+
|
|
41
|
+
Failures yield an error result without replacing the active project. The
|
|
42
|
+
caller owns the scope's lifetime and application-specific configuration use.
|
|
43
|
+
"""
|
|
44
|
+
filename = settings.static_config.get().config_filename
|
|
45
|
+
if filename is None:
|
|
46
|
+
raise errors.ConfigFilenameNotSet()
|
|
47
|
+
selected = locate_config(filename, path=path, cwd=cwd)
|
|
48
|
+
with _load_project_config(selected, config_class) as result:
|
|
49
|
+
yield result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def initialize_project[T: pydantic.BaseModel](
|
|
54
|
+
config_class: type[T],
|
|
55
|
+
*,
|
|
56
|
+
package: str,
|
|
57
|
+
template: str,
|
|
58
|
+
cwd: PathInput,
|
|
59
|
+
path: ProjectConfigPath | None = None,
|
|
60
|
+
) -> Iterator[Result[T]]:
|
|
61
|
+
"""Create and validate a starter, binding its project for the scope.
|
|
62
|
+
|
|
63
|
+
Creation never searches parent directories or overwrites an existing file.
|
|
64
|
+
A created file remains on disk if subsequent validation fails.
|
|
65
|
+
"""
|
|
66
|
+
filename = settings.static_config.get().config_filename
|
|
67
|
+
if filename is None:
|
|
68
|
+
raise errors.ConfigFilenameNotSet()
|
|
69
|
+
selected = initialize_config(filename, package=package, template=template, cwd=cwd, path=path)
|
|
70
|
+
with _load_project_config(selected, config_class) as result:
|
|
71
|
+
yield result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def _load_project_config[T: pydantic.BaseModel](
|
|
76
|
+
selected: Result[ProjectConfigPath], config_class: type[T]
|
|
77
|
+
) -> Iterator[Result[T]]:
|
|
78
|
+
if selected.is_err():
|
|
79
|
+
yield Err(selected.unwrap_err())
|
|
80
|
+
return
|
|
81
|
+
path = selected.unwrap()
|
|
82
|
+
loaded = load_config(path, config_class)
|
|
83
|
+
if loaded.is_err():
|
|
84
|
+
yield loaded
|
|
85
|
+
return
|
|
86
|
+
with project_config.scope(ProjectConfig(config_path=path)):
|
|
87
|
+
yield loaded
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Effective runtime settings, independent of their input source."""
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.core.context import ContextValue
|
|
4
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
5
|
+
from llm_tool_cli.protocol import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeConfig(BaseEntity):
|
|
9
|
+
"""Complete settings for an execution, including commands without a project."""
|
|
10
|
+
|
|
11
|
+
protocol: Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
runtime_config = ContextValue[RuntimeConfig]("runtime_config")
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Scoped application, runtime, and project settings for consumer tests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.config.project import ProjectConfig, project_config
|
|
9
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
10
|
+
from llm_tool_cli.core.settings import ConfigFilename, StaticConfig, ToolLabel, static_config
|
|
11
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
12
|
+
from llm_tool_cli.protocol import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def project_static_settings() -> Iterator[StaticConfig]:
|
|
17
|
+
value = StaticConfig(tool_label=ToolLabel("TEST"), config_filename=ConfigFilename("tool.toml"))
|
|
18
|
+
with static_config.scope(value):
|
|
19
|
+
yield value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def runtime_settings() -> Iterator[RuntimeConfig]:
|
|
24
|
+
value = RuntimeConfig(protocol=Protocol.human)
|
|
25
|
+
with runtime_config.scope(value):
|
|
26
|
+
yield value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def project_settings(tmp_path: Path) -> Iterator[ProjectConfig]:
|
|
31
|
+
"""Bind a project rooted at the test directory without filesystem loading."""
|
|
32
|
+
value = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "config.toml"))
|
|
33
|
+
with project_config.scope(value):
|
|
34
|
+
yield value
|