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,64 @@
1
+ from contextvars import Context
2
+
3
+ import pytest
4
+
5
+ from llm_tool_cli.core import settings
6
+ from llm_tool_cli.core.errors import ContextValueAlreadySet, ContextValueNotSet, ToolLabelAlreadyInitialized
7
+ from llm_tool_cli.core.settings import ConfigFilename, ToolLabel, initialize
8
+ from llm_tool_cli.core.tests.fixtures import isolated_settings
9
+
10
+ __all__ = ["isolated_settings"]
11
+
12
+ pytestmark = pytest.mark.usefixtures("isolated_settings")
13
+
14
+
15
+ class TestInitialize:
16
+ @pytest.mark.parametrize("label", ["TOOL", "", " café 日本語 "])
17
+ def test_installs_exact_label(self, label: str) -> None:
18
+ initialize(label=ToolLabel(label))
19
+
20
+ assert settings.static_config.get().tool_label == label
21
+
22
+ def test_same_label_is_idempotent(self) -> None:
23
+ initialize(label=ToolLabel("TOOL"))
24
+ initialize(label=ToolLabel("TOOL"))
25
+
26
+ assert settings.static_config.get().tool_label == "TOOL"
27
+
28
+ def test_same_application_settings_are_idempotent(self) -> None:
29
+ initialize(label=ToolLabel("TOOL"), config_filename=ConfigFilename("tool.toml"))
30
+ initialize(label=ToolLabel("TOOL"), config_filename=ConfigFilename("tool.toml"))
31
+
32
+ assert settings.static_config.get().config_filename == "tool.toml"
33
+
34
+ @pytest.mark.parametrize("filename", [None, ConfigFilename("other.toml")])
35
+ def test_conflicting_filename_preserves_original(self, filename: ConfigFilename | None) -> None:
36
+ initialize(label=ToolLabel("TOOL"), config_filename=ConfigFilename("tool.toml"))
37
+
38
+ with pytest.raises(ContextValueAlreadySet) as caught:
39
+ initialize(label=ToolLabel("TOOL"), config_filename=filename)
40
+
41
+ assert caught.value.details == {"name": "static_config"}
42
+ assert settings.static_config.get().config_filename == "tool.toml"
43
+
44
+ def test_conflicting_label_preserves_original(self) -> None:
45
+ initialize(label=ToolLabel(""))
46
+
47
+ with pytest.raises(ToolLabelAlreadyInitialized) as caught:
48
+ initialize(label=ToolLabel("OTHER"))
49
+
50
+ assert caught.value.details == {"current": "", "requested": "OTHER"}
51
+ assert settings.static_config.get().tool_label == ""
52
+
53
+ def test_initialization_uses_current_execution_context(self) -> None:
54
+ initialize(label=ToolLabel("OUTER"))
55
+ execution = Context()
56
+
57
+ with pytest.raises(ContextValueNotSet) as caught:
58
+ execution.run(settings.static_config.get)
59
+
60
+ assert caught.value.details == {"name": "static_config"}
61
+ execution.run(initialize, label=ToolLabel("INNER"))
62
+
63
+ assert execution.run(settings.static_config.get).tool_label == "INNER"
64
+ assert settings.static_config.get().tool_label == "OUTER"
@@ -0,0 +1,3 @@
1
+ from llm_tool_cli.output.emitter import JournalCellFactory, OutputEmitter, StreamOutputEmitter, output
2
+
3
+ __all__ = ["JournalCellFactory", "OutputEmitter", "StreamOutputEmitter", "output"]
@@ -0,0 +1,108 @@
1
+ import sys
2
+ from types import EllipsisType
3
+ from typing import Protocol, TextIO
4
+
5
+ from llm_tool_cli.config.runtime import runtime_config
6
+ from llm_tool_cli.core import settings
7
+ from llm_tool_cli.core.context import ContextValue
8
+ from llm_tool_cli.core.result import Ok, Result, unwrap_to_error
9
+ from llm_tool_cli.output.journal import JournalProcessor, NoopJournalProcessor
10
+ from llm_tool_cli.protocol import Protocol as OutputProtocol
11
+ from llm_tool_cli.protocol.logic_cells.base import LogicCell
12
+ from llm_tool_cli.protocol.logic_cells.journal import JournalCell
13
+ from llm_tool_cli.protocol.output_cells.base import RenderContext
14
+
15
+
16
+ class JournalCellFactory(Protocol):
17
+ __slots__ = ()
18
+
19
+ def __call__(self, *, message: str, actor_id: str | None) -> JournalCell: ... # noqa: E704
20
+
21
+
22
+ class OutputEmitter(Protocol):
23
+ __slots__ = ()
24
+
25
+ def emit(self, cells: list[LogicCell]) -> Result[None]: ... # noqa: E704
26
+
27
+ def journal(self, message: str, *, actor_id: str | None | EllipsisType = ...) -> Result[None]: ... # noqa: E704
28
+
29
+ def flush(self) -> None: ... # noqa: E704
30
+
31
+ def discard(self) -> None:
32
+ """Discard pending ordinary results, retaining warnings for failure reporting."""
33
+ ...
34
+
35
+
36
+ output = ContextValue[OutputEmitter]("output")
37
+
38
+
39
+ class StreamOutputEmitter:
40
+ """Deliver journals now, then buffered results and warnings at finalization."""
41
+
42
+ __slots__ = ("_cells", "_warnings", "_stream", "_journal_failed", "journal_processor", "journal_factory")
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ stream: TextIO | None = None,
48
+ journal_processor: JournalProcessor | None = None,
49
+ journal_factory: JournalCellFactory = JournalCell,
50
+ ) -> None:
51
+ self._stream = stream
52
+ self.journal_processor = journal_processor if journal_processor is not None else NoopJournalProcessor()
53
+ self.journal_factory = journal_factory
54
+ # A processor failure disables forwarding for this emitter's lifetime.
55
+ self._journal_failed = False
56
+ self._cells: list[LogicCell] = []
57
+ self._warnings: list[LogicCell] = []
58
+
59
+ def journal(self, message: str, *, actor_id: str | None | EllipsisType = ...) -> Result[None]:
60
+ if actor_id is ...:
61
+ protocol = runtime_config.get().protocol
62
+ actor_id = "agent" if protocol == OutputProtocol.llm else protocol.value
63
+ return self.emit([self.journal_factory(message=message, actor_id=actor_id)])
64
+
65
+ @unwrap_to_error
66
+ def emit(self, cells: list[LogicCell]) -> Result[None]: # noqa: CCR001
67
+ for cell in cells:
68
+ if cell.is_journal and not self._journal_failed:
69
+ processed = self.journal_processor.process(cell.to_journal_record())
70
+ if processed.is_err():
71
+ self._journal_failed = True
72
+ return processed
73
+ if cell.is_warning:
74
+ self._warnings.append(cell)
75
+ elif cell.is_journal:
76
+ self._write([cell])
77
+ else:
78
+ self._cells.append(cell)
79
+ return Ok(None)
80
+
81
+ def flush(self) -> None:
82
+ cells = [*self._cells, *self._warnings]
83
+ self._cells.clear()
84
+ self._warnings.clear()
85
+ self._write(cells)
86
+
87
+ def discard(self) -> None:
88
+ self._cells.clear()
89
+
90
+ def _write(self, cells: list[LogicCell]) -> None:
91
+ if not cells:
92
+ return
93
+ # Render the complete batch before writing so rendering failures cannot
94
+ # expose partial output. A failed stream write is not retried.
95
+ text = self._render(cells)
96
+ stream = self._stream if self._stream is not None else sys.stdout
97
+ stream.write(text)
98
+ stream.flush()
99
+
100
+ def _render(self, cells: list[LogicCell]) -> str:
101
+ """Prepare complete batch text; subclasses may override without handling streams."""
102
+ protocol = runtime_config.get().protocol
103
+ tool_label = settings.static_config.get().tool_label
104
+ sequence = [output_cell for cell in cells for output_cell in cell.render(protocol)]
105
+ return b"".join(
106
+ cell.render(RenderContext(index=index, total=len(sequence), tool_label=tool_label))
107
+ for index, cell in enumerate(sequence)
108
+ ).decode("utf-8")
@@ -0,0 +1,21 @@
1
+ from llm_tool_cli.core.errors import EnvironmentError as BaseEnvironmentError
2
+
3
+
4
+ class EnvironmentError(BaseEnvironmentError):
5
+ """Expected failures in output delivery."""
6
+
7
+
8
+ class JournalCommandConfigInvalid(EnvironmentError):
9
+ code: str = "journal_command_config_invalid"
10
+ message: str = "Journal command config is invalid: {error.details}"
11
+ argument: str
12
+ details: str
13
+
14
+
15
+ class JournalCommandFailed(EnvironmentError):
16
+ code: str = "journal_command_failed"
17
+ message: str = "Journal command failed: {error.details}"
18
+ ways_to_fix: list[str] = ["Check the configured journal command and its arguments."]
19
+ command: list[str]
20
+ returncode: int | None
21
+ details: str
@@ -0,0 +1,66 @@
1
+ import subprocess # noqa: S404
2
+ from typing import Protocol
3
+
4
+ import pydantic
5
+
6
+ from llm_tool_cli.core.entities import BaseEntity
7
+ from llm_tool_cli.core.result import Err, Ok, Result
8
+ from llm_tool_cli.output.errors import JournalCommandConfigInvalid, JournalCommandFailed
9
+ from llm_tool_cli.protocol.journal import JournalRecord
10
+
11
+
12
+ class JournalProcessor(Protocol):
13
+ __slots__ = ()
14
+
15
+ def process(self, record: JournalRecord) -> Result[None]: ... # noqa: E704
16
+
17
+
18
+ class NoopJournalProcessor:
19
+ __slots__ = ()
20
+
21
+ def process(self, record: JournalRecord) -> Result[None]:
22
+ return Ok(None)
23
+
24
+
25
+ class CommandJournalProcessor:
26
+ """Forward record fields as whole arguments, without a shell or text flattening."""
27
+
28
+ __slots__ = ("command",)
29
+
30
+ def __init__(self, command: list[str]) -> None:
31
+ self.command = tuple(command)
32
+
33
+ def process(self, record: JournalRecord) -> Result[None]: # noqa: CCR001
34
+ if not self.command:
35
+ return Err([JournalCommandConfigInvalid(argument="", details="Configured command is empty.")])
36
+
37
+ fields = record.as_dict()
38
+ args = []
39
+ for argument in self.command:
40
+ if len(argument) >= 2 and argument.startswith("{") and argument.endswith("}"):
41
+ value = fields.get(argument[1:-1])
42
+ argument = "" if value is None else str(value)
43
+ args.append(argument)
44
+
45
+ try:
46
+ result = subprocess.run(args, check=False, capture_output=True, text=True) # noqa: S603
47
+ except OSError as error:
48
+ return Err([JournalCommandFailed(command=args, returncode=None, details=str(error))])
49
+
50
+ if result.returncode:
51
+ details = f"exit code {result.returncode}"
52
+ if result.stderr.strip():
53
+ details += f"; stderr: {result.stderr.strip()}"
54
+ return Err([JournalCommandFailed(command=args, returncode=result.returncode, details=details)])
55
+ return Ok(None)
56
+
57
+
58
+ class JournalConfig(BaseEntity):
59
+ """Optional external command forwarding."""
60
+
61
+ cmd: list[str] | None = pydantic.Field(default=None, min_length=1)
62
+
63
+ def processor(self) -> JournalProcessor:
64
+ if self.cmd is None:
65
+ return NoopJournalProcessor()
66
+ return CommandJournalProcessor(self.cmd)
File without changes
@@ -0,0 +1,29 @@
1
+ from llm_tool_cli.core.result import Ok, Result
2
+ from llm_tool_cli.output import JournalCellFactory, StreamOutputEmitter, output
3
+ from llm_tool_cli.protocol.logic_cells.base import LogicCell
4
+ from llm_tool_cli.protocol.logic_cells.journal import JournalCell
5
+ from llm_tool_cli.protocol.logic_cells.warning import WarningCell
6
+
7
+
8
+ class CollectingEmitter(StreamOutputEmitter):
9
+ """Capture submitted logical cells without rendering or external side effects."""
10
+
11
+ def __init__(self, *, journal_factory: JournalCellFactory = JournalCell) -> None:
12
+ super().__init__(journal_factory=journal_factory)
13
+ self.cells: list[LogicCell] = []
14
+
15
+ def emit(self, cells: list[LogicCell]) -> Result[None]:
16
+ self.cells.extend(cells)
17
+ return Ok(None)
18
+
19
+ def flush(self) -> None:
20
+ pass
21
+
22
+ def discard(self) -> None:
23
+ self.cells = [cell for cell in self.cells if cell.is_warning]
24
+
25
+
26
+ def warning_messages() -> list[str]:
27
+ emitter = output.get()
28
+ assert isinstance(emitter, CollectingEmitter)
29
+ return [cell.message for cell in emitter.cells if isinstance(cell, WarningCell)]