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,167 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import typer
|
|
3
|
+
from pytest_mock import MockerFixture
|
|
4
|
+
from typer.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.cli.application import create_app
|
|
7
|
+
from llm_tool_cli.cli.context import CommandContext
|
|
8
|
+
from llm_tool_cli.config.project import project_config
|
|
9
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
10
|
+
from llm_tool_cli.core.settings import ToolLabel, initialize
|
|
11
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
12
|
+
from llm_tool_cli.protocol import Protocol
|
|
13
|
+
|
|
14
|
+
__all__ = ["isolated_settings"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def app() -> typer.Typer:
|
|
19
|
+
application = create_app(help="Inspect test resources.")
|
|
20
|
+
|
|
21
|
+
@application.command(help="Show a test resource.")
|
|
22
|
+
def show() -> None:
|
|
23
|
+
raise AssertionError("Help and completion must not execute the command.")
|
|
24
|
+
|
|
25
|
+
return application
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestCreateApp:
|
|
29
|
+
@pytest.mark.parametrize("option", ["-p", "--protocol"])
|
|
30
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
31
|
+
def test_nested_commands_and_repeated_invocations(self, option: str, protocol: Protocol) -> None:
|
|
32
|
+
application = create_app(help="Inspect nested resources.")
|
|
33
|
+
group = typer.Typer()
|
|
34
|
+
application.add_typer(group, name="nested")
|
|
35
|
+
received: list[RuntimeConfig] = []
|
|
36
|
+
|
|
37
|
+
@group.command()
|
|
38
|
+
def show(context: typer.Context) -> None:
|
|
39
|
+
command = CommandContext(context)
|
|
40
|
+
received.append(runtime_config.get())
|
|
41
|
+
assert command.config_input == context.find_root().params["config_path"]
|
|
42
|
+
assert context.obj == {"other": "state"}
|
|
43
|
+
assert context.find_root().meta == {}
|
|
44
|
+
|
|
45
|
+
runner = CliRunner()
|
|
46
|
+
explicit = runner.invoke(
|
|
47
|
+
application,
|
|
48
|
+
[option, protocol.value, "--config", "~/custom.toml", "nested", "show"],
|
|
49
|
+
obj={"other": "state"},
|
|
50
|
+
)
|
|
51
|
+
assert not runtime_config.is_set()
|
|
52
|
+
unspecified = runner.invoke(application, ["nested", "show"], obj={"other": "state"})
|
|
53
|
+
assert not runtime_config.is_set()
|
|
54
|
+
|
|
55
|
+
assert explicit.exit_code == unspecified.exit_code == 0
|
|
56
|
+
assert explicit.stdout == explicit.stderr == unspecified.stdout == unspecified.stderr == ""
|
|
57
|
+
assert received == [
|
|
58
|
+
RuntimeConfig(protocol=protocol),
|
|
59
|
+
RuntimeConfig(protocol=Protocol.human),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize(
|
|
63
|
+
("failure", "exit_code"),
|
|
64
|
+
[(None, 0), (typer.Exit(7), 7), (RuntimeError("command failed"), 1)],
|
|
65
|
+
)
|
|
66
|
+
def test_restores_outer_runtime_after_command(self, failure: Exception | None, exit_code: int) -> None:
|
|
67
|
+
application = create_app(help="Inspect scoped options.")
|
|
68
|
+
received: list[RuntimeConfig] = []
|
|
69
|
+
|
|
70
|
+
@application.command()
|
|
71
|
+
def show(context: typer.Context) -> None:
|
|
72
|
+
command = CommandContext(context)
|
|
73
|
+
received.append(runtime_config.get())
|
|
74
|
+
assert command.protocol == Protocol.human
|
|
75
|
+
if failure is not None:
|
|
76
|
+
raise failure
|
|
77
|
+
|
|
78
|
+
outer = RuntimeConfig(protocol=Protocol.automation)
|
|
79
|
+
with runtime_config.scope(outer):
|
|
80
|
+
result = CliRunner().invoke(application, ["show"])
|
|
81
|
+
|
|
82
|
+
assert result.exit_code == exit_code
|
|
83
|
+
assert received == [RuntimeConfig(protocol=Protocol.human)]
|
|
84
|
+
assert runtime_config.get() == outer
|
|
85
|
+
|
|
86
|
+
assert not runtime_config.is_set()
|
|
87
|
+
if failure is not None and not isinstance(failure, typer.Exit):
|
|
88
|
+
assert isinstance(result.exception, type(failure))
|
|
89
|
+
assert result.exception.args == failure.args
|
|
90
|
+
|
|
91
|
+
def test_subcommand_parse_error_leaves_runtime_unbound(self, app: typer.Typer) -> None:
|
|
92
|
+
result = CliRunner().invoke(app, ["-p", "automation", "show", "--unknown"])
|
|
93
|
+
|
|
94
|
+
assert result.exit_code == 2
|
|
95
|
+
assert "No such option" in result.stderr
|
|
96
|
+
assert not runtime_config.is_set()
|
|
97
|
+
|
|
98
|
+
def test_invalid_protocol_stops_execution(self, app: typer.Typer, isolated_settings: None) -> None:
|
|
99
|
+
initialize(label=ToolLabel("TEST"))
|
|
100
|
+
|
|
101
|
+
result = CliRunner().invoke(app, ["--protocol", "invalid", "show"])
|
|
102
|
+
|
|
103
|
+
assert result.exit_code == 1
|
|
104
|
+
assert not result.stderr
|
|
105
|
+
assert result.stdout.startswith("--TEST-CELL ")
|
|
106
|
+
assert result.stdout.count(" BEGIN--\n") == 1
|
|
107
|
+
assert "code=invalid_arguments\n" in result.stdout
|
|
108
|
+
assert not runtime_config.is_set()
|
|
109
|
+
assert not project_config.is_set()
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize("option", ["-p", "--protocol", "--config"])
|
|
112
|
+
def test_missing_option_value(self, app: typer.Typer, option: str) -> None:
|
|
113
|
+
result = CliRunner().invoke(app, [option])
|
|
114
|
+
|
|
115
|
+
assert result.exit_code == 2
|
|
116
|
+
assert not result.stdout
|
|
117
|
+
assert "requires an argument" in result.stderr
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("option", ["-h", "--help"])
|
|
120
|
+
@pytest.mark.parametrize("command", [[], ["show"]])
|
|
121
|
+
def test_help_aliases(self, app: typer.Typer, option: str, command: list[str]) -> None:
|
|
122
|
+
result = CliRunner().invoke(app, [*command, option])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 0
|
|
125
|
+
assert not result.stderr
|
|
126
|
+
assert not runtime_config.is_set()
|
|
127
|
+
assert ("Show a test resource." if command else "Inspect test resources.") in result.stdout
|
|
128
|
+
assert "--help" in result.stdout
|
|
129
|
+
assert "-h" in result.stdout
|
|
130
|
+
if not command:
|
|
131
|
+
assert "--protocol" in result.stdout
|
|
132
|
+
assert "-p" in result.stdout
|
|
133
|
+
assert "--config" in result.stdout
|
|
134
|
+
assert "--show-completion" in result.stdout
|
|
135
|
+
assert "--install-completion" in result.stdout
|
|
136
|
+
|
|
137
|
+
def test_show_completion_does_not_install(self, app: typer.Typer, mocker: MockerFixture) -> None:
|
|
138
|
+
mocker.patch("typer.completion._get_shell_name", return_value="bash")
|
|
139
|
+
install = mocker.patch("typer.completion.install")
|
|
140
|
+
|
|
141
|
+
result = CliRunner().invoke(app, ["--show-completion"], prog_name="sample")
|
|
142
|
+
|
|
143
|
+
assert result.exit_code == 0
|
|
144
|
+
assert not result.stderr
|
|
145
|
+
assert "_SAMPLE_COMPLETE" in result.stdout
|
|
146
|
+
assert "complete" in result.stdout
|
|
147
|
+
install.assert_not_called()
|
|
148
|
+
|
|
149
|
+
def test_install_completion_is_explicit(self, app: typer.Typer, mocker: MockerFixture) -> None:
|
|
150
|
+
install = mocker.patch("typer.completion.install", return_value=("bash", "/test/completion.sh"))
|
|
151
|
+
|
|
152
|
+
assert CliRunner().invoke(app, ["--help"]).exit_code == 0
|
|
153
|
+
install.assert_not_called()
|
|
154
|
+
|
|
155
|
+
result = CliRunner().invoke(app, ["--install-completion"])
|
|
156
|
+
|
|
157
|
+
assert result.exit_code == 0
|
|
158
|
+
assert not result.stderr
|
|
159
|
+
install.assert_called_once_with()
|
|
160
|
+
|
|
161
|
+
def test_applications_have_independent_commands(self, app: typer.Typer) -> None:
|
|
162
|
+
other = create_app(help="Other resources.")
|
|
163
|
+
|
|
164
|
+
result = CliRunner().invoke(other, ["show"])
|
|
165
|
+
|
|
166
|
+
assert result.exit_code == 2
|
|
167
|
+
assert "No such command" in result.stderr
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import typer
|
|
7
|
+
from typer.core import TyperCommand
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from llm_tool_cli.cli.application import create_app
|
|
11
|
+
from llm_tool_cli.cli.context import CommandContext, command_context
|
|
12
|
+
from llm_tool_cli.cli.errors import InvalidArguments
|
|
13
|
+
from llm_tool_cli.config.project import ProjectConfig, project_config
|
|
14
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
15
|
+
from llm_tool_cli.core.result import UnwrapError
|
|
16
|
+
from llm_tool_cli.core.settings import ToolLabel, initialize
|
|
17
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
18
|
+
from llm_tool_cli.output import StreamOutputEmitter
|
|
19
|
+
from llm_tool_cli.output import output as active_output
|
|
20
|
+
from llm_tool_cli.output.tests.helpers import CollectingEmitter
|
|
21
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
22
|
+
from llm_tool_cli.protocol import Protocol
|
|
23
|
+
from llm_tool_cli.protocol.cell_shortcuts import info, operation_succeeded
|
|
24
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
25
|
+
from llm_tool_cli.protocol.logic_cells.warning import WarningCell
|
|
26
|
+
|
|
27
|
+
__all__ = ["isolated_settings"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.usefixtures("isolated_settings")
|
|
31
|
+
class TestCommandContext:
|
|
32
|
+
def test_command_scope_without_project_flushes_and_restores_output(
|
|
33
|
+
self, capsys: pytest.CaptureFixture[str]
|
|
34
|
+
) -> None:
|
|
35
|
+
initialize(label=ToolLabel("TEST"))
|
|
36
|
+
previous = CollectingEmitter()
|
|
37
|
+
with active_output.scope(previous):
|
|
38
|
+
with typer.Context(TyperCommand("show")) as context:
|
|
39
|
+
with command_context(context):
|
|
40
|
+
assert not project_config.is_set()
|
|
41
|
+
active_output.get().emit([info("Without a project")]).unwrap()
|
|
42
|
+
assert not capsys.readouterr().out
|
|
43
|
+
assert "Without a project" in capsys.readouterr().out
|
|
44
|
+
assert active_output.get() == previous
|
|
45
|
+
assert not previous.cells
|
|
46
|
+
assert not active_output.is_set()
|
|
47
|
+
assert not runtime_config.is_set()
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize(
|
|
50
|
+
"failure, exit_code, record_types",
|
|
51
|
+
[
|
|
52
|
+
(None, 0, ["operation_succeeded", "warning"]),
|
|
53
|
+
(UnwrapError(error=[InvalidArguments(reason="Invalid input")]), 1, ["error", "warning"]),
|
|
54
|
+
(RuntimeError("Unexpected failure"), 1, []),
|
|
55
|
+
(typer.Exit(7), 7, []),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
def test_command_scope_finalizes_using_active_emitter_before_framework_cleanup(
|
|
59
|
+
self, tmp_path: Path, failure: Exception | None, exit_code: int, record_types: list[str]
|
|
60
|
+
) -> None:
|
|
61
|
+
initialize(label=ToolLabel("TEST"))
|
|
62
|
+
project = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "active.toml"))
|
|
63
|
+
stream = io.StringIO()
|
|
64
|
+
rendered: list[list[LogicCell]] = []
|
|
65
|
+
|
|
66
|
+
class ProjectEmitter(StreamOutputEmitter):
|
|
67
|
+
def _render(self, cells: list[LogicCell]) -> str:
|
|
68
|
+
assert project_config.get() == project
|
|
69
|
+
assert runtime_config.get().protocol == Protocol.automation
|
|
70
|
+
rendered.append(cells)
|
|
71
|
+
return super()._render(cells)
|
|
72
|
+
|
|
73
|
+
app = create_app(help="Exercise command finalization.")
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def show(context: typer.Context) -> None:
|
|
77
|
+
with command_context(context):
|
|
78
|
+
context.with_resource(project_config.scope(project))
|
|
79
|
+
context.with_resource(active_output.scope(ProjectEmitter(stream=stream)))
|
|
80
|
+
active_output.get().emit([WarningCell(message="A warning")]).unwrap()
|
|
81
|
+
active_output.get().emit([operation_succeeded("Completed")]).unwrap()
|
|
82
|
+
assert not stream.getvalue()
|
|
83
|
+
if failure is not None:
|
|
84
|
+
raise failure
|
|
85
|
+
|
|
86
|
+
previous_output = CollectingEmitter()
|
|
87
|
+
previous_runtime = RuntimeConfig(protocol=Protocol.llm)
|
|
88
|
+
previous_project = ProjectConfig(config_path=ProjectConfigPath(tmp_path / "outer.toml"))
|
|
89
|
+
with (
|
|
90
|
+
active_output.scope(previous_output),
|
|
91
|
+
runtime_config.scope(previous_runtime),
|
|
92
|
+
project_config.scope(previous_project),
|
|
93
|
+
):
|
|
94
|
+
result = CliRunner().invoke(app, ["-p", "automation", "show"])
|
|
95
|
+
assert active_output.get() == previous_output
|
|
96
|
+
assert runtime_config.get() == previous_runtime
|
|
97
|
+
assert project_config.get() == previous_project
|
|
98
|
+
|
|
99
|
+
assert result.exit_code == exit_code, result.exception
|
|
100
|
+
assert not result.stdout
|
|
101
|
+
assert not result.stderr
|
|
102
|
+
assert [json.loads(line)["type"] for line in stream.getvalue().splitlines()] == record_types
|
|
103
|
+
assert bool(rendered) == bool(record_types)
|
|
104
|
+
assert not previous_output.cells
|
|
105
|
+
if failure is not None and not isinstance(failure, (typer.Exit, UnwrapError)):
|
|
106
|
+
assert result.exception == failure
|
|
107
|
+
|
|
108
|
+
def test_init__nested_skill_uses_leaf_command_default(self) -> None:
|
|
109
|
+
with typer.Context(TyperCommand("root")) as root:
|
|
110
|
+
with typer.Context(TyperCommand("group"), parent=root) as group:
|
|
111
|
+
with typer.Context(TyperCommand("skill"), parent=group, info_name="skill") as context:
|
|
112
|
+
assert CommandContext(context).protocol == Protocol.llm
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
("name", "expected"),
|
|
116
|
+
[("skill", Protocol.llm), ("version", Protocol.human), ("alias", Protocol.human), (None, Protocol.human)],
|
|
117
|
+
)
|
|
118
|
+
def test_init__selects_default_for_invoked_name(self, name: str | None, expected: Protocol) -> None:
|
|
119
|
+
with typer.Context(TyperCommand("different-name"), info_name=name) as context:
|
|
120
|
+
command = CommandContext(context)
|
|
121
|
+
assert command.protocol == expected
|
|
122
|
+
assert command.config_input is None
|
|
123
|
+
assert not project_config.is_set()
|
|
124
|
+
assert context.meta == {}
|
|
125
|
+
assert not runtime_config.is_set()
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
128
|
+
def test_init__uses_root_inputs_for_nested_command(self, protocol: Protocol) -> None:
|
|
129
|
+
with typer.Context(TyperCommand("root")) as root:
|
|
130
|
+
root.params.update(protocol=protocol, config_path=ProjectConfigPath(Path("~/missing.toml")))
|
|
131
|
+
with typer.Context(TyperCommand("group"), parent=root) as group:
|
|
132
|
+
with typer.Context(TyperCommand("skill"), parent=group, info_name="skill") as child:
|
|
133
|
+
command = CommandContext(child)
|
|
134
|
+
assert command.protocol == protocol
|
|
135
|
+
assert command.config_input == Path("~/missing.toml")
|
|
136
|
+
assert not project_config.is_set()
|
|
137
|
+
assert not runtime_config.is_set()
|
|
138
|
+
|
|
139
|
+
def test_protocol__reads_current_runtime_instead_of_retaining_a_copy(self) -> None:
|
|
140
|
+
with typer.Context(TyperCommand("show"), info_name="show") as context:
|
|
141
|
+
context.params["protocol"] = Protocol.automation
|
|
142
|
+
command = CommandContext(context)
|
|
143
|
+
observed = [command.protocol]
|
|
144
|
+
with runtime_config.scope(RuntimeConfig(protocol=Protocol.llm)):
|
|
145
|
+
observed.append(command.protocol)
|
|
146
|
+
observed.append(command.protocol)
|
|
147
|
+
assert observed == [Protocol.automation, Protocol.llm, Protocol.automation]
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
150
|
+
@pytest.mark.parametrize("custom_stream", [False, True])
|
|
151
|
+
def test_init__binds_output_with_selected_protocol_and_supports_stream_override(
|
|
152
|
+
self, capsys: pytest.CaptureFixture[str], protocol: Protocol, custom_stream: bool
|
|
153
|
+
) -> None:
|
|
154
|
+
initialize(label=ToolLabel("TEST"))
|
|
155
|
+
messages = ["Café", "日本語"]
|
|
156
|
+
stream = io.StringIO()
|
|
157
|
+
|
|
158
|
+
with typer.Context(TyperCommand("show"), info_name="show") as context:
|
|
159
|
+
context.params["protocol"] = protocol
|
|
160
|
+
CommandContext(context)
|
|
161
|
+
if custom_stream:
|
|
162
|
+
context.with_resource(active_output.scope(StreamOutputEmitter(stream=stream)))
|
|
163
|
+
active_output.get().emit([info(message) for message in messages]).unwrap()
|
|
164
|
+
assert capsys.readouterr() == ("", "")
|
|
165
|
+
assert not stream.getvalue()
|
|
166
|
+
active_output.get().flush()
|
|
167
|
+
|
|
168
|
+
captured = capsys.readouterr()
|
|
169
|
+
output = stream.getvalue() if custom_stream else captured.out
|
|
170
|
+
assert not (captured.out if custom_stream else stream.getvalue())
|
|
171
|
+
assert not captured.err
|
|
172
|
+
if protocol == Protocol.automation:
|
|
173
|
+
records = [json.loads(line) for line in output.splitlines()]
|
|
174
|
+
assert [record["content"] for record in records] == messages
|
|
175
|
+
elif protocol == Protocol.human:
|
|
176
|
+
assert output == "Café\n\n日本語\n\n"
|
|
177
|
+
else:
|
|
178
|
+
assert output.startswith("--TEST-CELL ")
|
|
179
|
+
assert output.index(messages[0]) < output.index(messages[1])
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import pytest
|
|
6
|
+
import typer
|
|
7
|
+
from pytest_mock import MockerFixture
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from llm_tool_cli.cli.errors import InvalidArguments
|
|
11
|
+
from llm_tool_cli.cli.handling import handle_command_errors, report_errors_and_exit
|
|
12
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
13
|
+
from llm_tool_cli.core.errors import EnvironmentError, EnvironmentErrors, InternalError
|
|
14
|
+
from llm_tool_cli.core.result import Err, Ok, UnwrapError
|
|
15
|
+
from llm_tool_cli.core.settings import ToolLabel, initialize
|
|
16
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
17
|
+
from llm_tool_cli.output import StreamOutputEmitter, output
|
|
18
|
+
from llm_tool_cli.output.errors import JournalCommandFailed
|
|
19
|
+
from llm_tool_cli.protocol import Protocol
|
|
20
|
+
from llm_tool_cli.protocol.logic_cells import ContentCell
|
|
21
|
+
from llm_tool_cli.protocol.logic_cells.journal import JournalCell
|
|
22
|
+
from llm_tool_cli.protocol.logic_cells.warning import WarningCell
|
|
23
|
+
|
|
24
|
+
__all__ = ["isolated_settings"]
|
|
25
|
+
|
|
26
|
+
pytestmark = pytest.mark.usefixtures("isolated_settings")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestReportErrorsAndExit:
|
|
30
|
+
def test_errors_are_reported_without_journal_processing(self, mocker: MockerFixture) -> None:
|
|
31
|
+
initialize(label=ToolLabel("TEST"))
|
|
32
|
+
failures: EnvironmentErrors = [
|
|
33
|
+
InvalidArguments(reason="first\nsecond"),
|
|
34
|
+
EnvironmentError(code="unavailable", message="Service unavailable"),
|
|
35
|
+
JournalCommandFailed(command=["another-tool"], returncode=7, details="failed elsewhere"),
|
|
36
|
+
]
|
|
37
|
+
original = [error.as_record() for error in failures]
|
|
38
|
+
processor = mocker.Mock()
|
|
39
|
+
processor.process.side_effect = AssertionError("error reporting must not invoke a journal processor")
|
|
40
|
+
factory = mocker.Mock(side_effect=AssertionError("error reporting must not invoke a journal factory"))
|
|
41
|
+
stream = io.StringIO()
|
|
42
|
+
emitter = StreamOutputEmitter(stream=stream, journal_processor=processor, journal_factory=factory)
|
|
43
|
+
emitter.emit([ContentCell(kind="success"), WarningCell(message="retained")]).unwrap()
|
|
44
|
+
with pytest.raises(typer.Exit) as caught:
|
|
45
|
+
report_errors_and_exit(failures, protocol=Protocol.automation, emitter=emitter)
|
|
46
|
+
assert caught.value.exit_code == 3
|
|
47
|
+
processor.process.assert_not_called()
|
|
48
|
+
factory.assert_not_called()
|
|
49
|
+
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
50
|
+
assert [record["code"] for record in records[:3]] == [error.code for error in failures]
|
|
51
|
+
assert records[0]["content"] == "first\nsecond"
|
|
52
|
+
assert records[0]["reason"] == "first\nsecond"
|
|
53
|
+
assert records[3]["message"] == "retained"
|
|
54
|
+
assert len(records) == 4
|
|
55
|
+
assert [error.as_record() for error in failures] == original
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("reverse", [False, True])
|
|
58
|
+
@pytest.mark.parametrize(
|
|
59
|
+
"delivery_failure",
|
|
60
|
+
[
|
|
61
|
+
JournalCommandFailed(command=["tool"], returncode=None, details="unavailable"),
|
|
62
|
+
EnvironmentError(code="custom_failure", message="unavailable"),
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
def test_failed_emitter_is_not_retried_when_reporting_mixed_errors(
|
|
66
|
+
self, mocker: MockerFixture, reverse: bool, delivery_failure: EnvironmentError
|
|
67
|
+
) -> None:
|
|
68
|
+
initialize(label=ToolLabel("TEST"))
|
|
69
|
+
processor = mocker.Mock()
|
|
70
|
+
processor.process.return_value = Err([delivery_failure])
|
|
71
|
+
stream = io.StringIO()
|
|
72
|
+
emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
|
|
73
|
+
failed = emitter.emit([JournalCell(message="failed event")])
|
|
74
|
+
errors: EnvironmentErrors = [InvalidArguments(reason="original"), *failed.unwrap_err()]
|
|
75
|
+
if reverse:
|
|
76
|
+
errors.reverse()
|
|
77
|
+
with pytest.raises(typer.Exit):
|
|
78
|
+
report_errors_and_exit(errors, protocol=Protocol.automation, emitter=emitter)
|
|
79
|
+
assert [json.loads(line)["code"] for line in stream.getvalue().splitlines()] == [
|
|
80
|
+
error.code for error in errors
|
|
81
|
+
]
|
|
82
|
+
processor.process.assert_called_once()
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
85
|
+
@pytest.mark.parametrize("reverse", [False, True])
|
|
86
|
+
def test_ordered_cells_stream_and_highest_exit_code( # noqa: CCR001
|
|
87
|
+
self, capsys: pytest.CaptureFixture[str], protocol: Protocol, reverse: bool
|
|
88
|
+
) -> None:
|
|
89
|
+
initialize(label=ToolLabel("TEST"))
|
|
90
|
+
errors: EnvironmentErrors = [
|
|
91
|
+
InvalidArguments(reason="Invalid café 日本語"),
|
|
92
|
+
EnvironmentError(code="unavailable", message="Service unavailable"),
|
|
93
|
+
]
|
|
94
|
+
if reverse:
|
|
95
|
+
errors.reverse()
|
|
96
|
+
original = [error.as_record() for error in errors]
|
|
97
|
+
|
|
98
|
+
with pytest.raises(typer.Exit) as caught:
|
|
99
|
+
report_errors_and_exit(errors, protocol=protocol)
|
|
100
|
+
|
|
101
|
+
assert caught.value.exit_code == 3
|
|
102
|
+
assert [error.as_record() for error in errors] == original
|
|
103
|
+
captured = capsys.readouterr()
|
|
104
|
+
if protocol == Protocol.automation:
|
|
105
|
+
assert not captured.err
|
|
106
|
+
records = [json.loads(line) for line in captured.out.splitlines()]
|
|
107
|
+
for record in records:
|
|
108
|
+
assert record.pop("id")
|
|
109
|
+
expected = [{**record, "content": record["message"]} for record in original]
|
|
110
|
+
for record in expected:
|
|
111
|
+
del record["message"]
|
|
112
|
+
assert records == expected
|
|
113
|
+
else:
|
|
114
|
+
assert not captured.err
|
|
115
|
+
separator = ": " if protocol == Protocol.human else "="
|
|
116
|
+
codes = [f"code{separator}{error.code}\n" for error in errors]
|
|
117
|
+
assert all(code in captured.out for code in codes)
|
|
118
|
+
positions = [captured.out.index(code) for code in codes]
|
|
119
|
+
assert positions == sorted(positions)
|
|
120
|
+
assert "café 日本語" in captured.out
|
|
121
|
+
assert "cli_exit_code" not in captured.out
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
124
|
+
def test_empty_errors_exit_successfully_without_output(
|
|
125
|
+
self, capsys: pytest.CaptureFixture[str], protocol: Protocol
|
|
126
|
+
) -> None:
|
|
127
|
+
initialize(label=ToolLabel("TEST"))
|
|
128
|
+
|
|
129
|
+
with pytest.raises(typer.Exit) as caught:
|
|
130
|
+
report_errors_and_exit([], protocol=protocol)
|
|
131
|
+
|
|
132
|
+
assert caught.value.exit_code == 0
|
|
133
|
+
captured = capsys.readouterr()
|
|
134
|
+
assert captured.out == captured.err == ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestHandleCommandErrors:
|
|
138
|
+
def test_success_continues_without_output_or_exit(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
139
|
+
with handle_command_errors(protocol=Protocol.llm):
|
|
140
|
+
value = Ok("completed").unwrap()
|
|
141
|
+
|
|
142
|
+
assert value == "completed"
|
|
143
|
+
captured = capsys.readouterr()
|
|
144
|
+
assert captured.out == captured.err == ""
|
|
145
|
+
|
|
146
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
147
|
+
def test_failed_command_stops_and_reports_errors(self, protocol: Protocol) -> None:
|
|
148
|
+
initialize(label=ToolLabel("TEST"))
|
|
149
|
+
app = typer.Typer()
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def fail() -> None:
|
|
153
|
+
with handle_command_errors(protocol=protocol):
|
|
154
|
+
Err([InvalidArguments(reason="invalid argument")]).unwrap()
|
|
155
|
+
typer.echo("unreachable")
|
|
156
|
+
typer.echo("also unreachable")
|
|
157
|
+
|
|
158
|
+
result = CliRunner().invoke(app, [])
|
|
159
|
+
|
|
160
|
+
assert result.exit_code == 1
|
|
161
|
+
assert "unreachable" not in result.output
|
|
162
|
+
if protocol == Protocol.automation:
|
|
163
|
+
assert not result.stderr
|
|
164
|
+
assert json.loads(result.stdout)["code"] == "invalid_arguments"
|
|
165
|
+
else:
|
|
166
|
+
assert not result.stderr
|
|
167
|
+
assert "invalid_arguments" in result.stdout
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize(
|
|
170
|
+
"failure",
|
|
171
|
+
[RuntimeError("bug"), InternalError("broken state"), typer.Exit(7), click.UsageError("bad usage")],
|
|
172
|
+
)
|
|
173
|
+
def test_unrelated_exceptions_propagate_without_output(
|
|
174
|
+
self, capsys: pytest.CaptureFixture[str], failure: Exception
|
|
175
|
+
) -> None:
|
|
176
|
+
with pytest.raises(type(failure)) as caught:
|
|
177
|
+
with handle_command_errors(protocol=Protocol.llm):
|
|
178
|
+
raise failure
|
|
179
|
+
|
|
180
|
+
assert caught.value == failure
|
|
181
|
+
captured = capsys.readouterr()
|
|
182
|
+
assert captured.out == captured.err == ""
|
|
183
|
+
|
|
184
|
+
@pytest.mark.parametrize("payload", [None, "invalid", [InvalidArguments(reason="invalid"), "malformed"]])
|
|
185
|
+
def test_malformed_unwrap_error_propagates_without_partial_output(
|
|
186
|
+
self, capsys: pytest.CaptureFixture[str], payload: object
|
|
187
|
+
) -> None:
|
|
188
|
+
failure = UnwrapError(error=[])
|
|
189
|
+
failure.details["error"] = payload
|
|
190
|
+
|
|
191
|
+
with pytest.raises(UnwrapError) as caught:
|
|
192
|
+
with handle_command_errors(protocol=Protocol.llm):
|
|
193
|
+
raise failure
|
|
194
|
+
|
|
195
|
+
assert caught.value == failure
|
|
196
|
+
captured = capsys.readouterr()
|
|
197
|
+
assert captured.out == captured.err == ""
|
|
198
|
+
|
|
199
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
200
|
+
@pytest.mark.parametrize("custom_stream", [False, True])
|
|
201
|
+
def test_discard__failure_outputs_error_then_warnings_without_results(
|
|
202
|
+
self, capsys: pytest.CaptureFixture[str], protocol: Protocol, custom_stream: bool
|
|
203
|
+
) -> None:
|
|
204
|
+
initialize(label=ToolLabel("TEST"))
|
|
205
|
+
stream = io.StringIO()
|
|
206
|
+
emitter = StreamOutputEmitter(stream=stream if custom_stream else None)
|
|
207
|
+
with runtime_config.scope(RuntimeConfig(protocol=protocol)), output.scope(emitter):
|
|
208
|
+
with pytest.raises(typer.Exit) as caught:
|
|
209
|
+
with handle_command_errors(protocol=protocol):
|
|
210
|
+
emitter.emit([ContentCell(kind="success"), WarningCell(message="keep warning")]).unwrap()
|
|
211
|
+
Err([EnvironmentError(code="failed", message="command failed")]).unwrap()
|
|
212
|
+
assert caught.value.exit_code == 3
|
|
213
|
+
captured = capsys.readouterr()
|
|
214
|
+
delivered = stream.getvalue() if custom_stream else captured.out
|
|
215
|
+
assert not (captured.out if custom_stream else stream.getvalue())
|
|
216
|
+
assert not captured.err
|
|
217
|
+
assert "success" not in delivered
|
|
218
|
+
assert delivered.index("command failed") < delivered.index("keep warning")
|
|
219
|
+
assert not stream.closed
|
|
220
|
+
if protocol == Protocol.automation:
|
|
221
|
+
assert [json.loads(line)["type"] for line in delivered.splitlines()] == ["error", "warning"]
|
|
222
|
+
|
|
223
|
+
def test_discard__unexpected_failure_does_not_flush_results(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
224
|
+
emitter = StreamOutputEmitter()
|
|
225
|
+
with output.scope(emitter), pytest.raises(RuntimeError, match="defect"):
|
|
226
|
+
with handle_command_errors(protocol=Protocol.human):
|
|
227
|
+
emitter.emit([ContentCell(kind="success")]).unwrap()
|
|
228
|
+
raise RuntimeError("defect")
|
|
229
|
+
emitter.flush()
|
|
230
|
+
assert not capsys.readouterr().out
|