llm-tool-cli 0.2.0__py3-none-any.whl

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