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,406 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest_mock import MockerFixture
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
|
|
9
|
+
from llm_tool_cli.config.tests.fixtures import project_static_settings, runtime_settings
|
|
10
|
+
from llm_tool_cli.core.errors import ContextValueNotSet, EnvironmentError
|
|
11
|
+
from llm_tool_cli.core.result import Err, Ok, Result
|
|
12
|
+
from llm_tool_cli.core.settings import StaticConfig, ToolLabel, static_config
|
|
13
|
+
from llm_tool_cli.core.tests.fixtures import isolated_settings
|
|
14
|
+
from llm_tool_cli.output import StreamOutputEmitter
|
|
15
|
+
from llm_tool_cli.protocol import Protocol
|
|
16
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
17
|
+
from llm_tool_cli.protocol.logic_cells import ContentCell, EnvironmentErrorCell
|
|
18
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
19
|
+
from llm_tool_cli.protocol.logic_cells.journal import JournalCell
|
|
20
|
+
from llm_tool_cli.protocol.logic_cells.warning import WarningCell
|
|
21
|
+
from llm_tool_cli.protocol.output_cells import HumanOutputCell
|
|
22
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
|
|
23
|
+
|
|
24
|
+
__all__ = ["isolated_settings", "project_static_settings", "runtime_settings"]
|
|
25
|
+
pytestmark = pytest.mark.usefixtures("project_static_settings", "runtime_settings")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecordingProcessor:
|
|
29
|
+
def __init__(self, result: Result[None] = Ok(None)) -> None:
|
|
30
|
+
self.records: list[JournalRecord] = []
|
|
31
|
+
self.result = result
|
|
32
|
+
|
|
33
|
+
def process(self, record: JournalRecord) -> Result[None]:
|
|
34
|
+
self.records.append(record)
|
|
35
|
+
return self.result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PositionedOutputCell(OutputCell):
|
|
39
|
+
def render(self, context: RenderContext) -> bytes:
|
|
40
|
+
return f"{context.tool_label} {context.index}/{context.total}: {self.content}\n".encode()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PositionedLogicCell(LogicCell):
|
|
44
|
+
items: tuple[str, ...]
|
|
45
|
+
|
|
46
|
+
def to_journal_record(self) -> JournalRecord:
|
|
47
|
+
return JournalRecord(message="\n".join(self.items))
|
|
48
|
+
|
|
49
|
+
def render_human(self) -> list[OutputCell]:
|
|
50
|
+
return [PositionedOutputCell.build_markdown(kind="item", content=text) for text in self.items]
|
|
51
|
+
|
|
52
|
+
def render_llm(self) -> list[OutputCell]:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
def render_automation(self) -> list[OutputCell]:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestStreamOutputEmitter:
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
"protocol, actor", [(Protocol.human, "human"), (Protocol.llm, "agent"), (Protocol.automation, "automation")]
|
|
62
|
+
)
|
|
63
|
+
def test_journal__uses_default_factory_and_protocol_actor(self, protocol: Protocol, actor: str) -> None:
|
|
64
|
+
stream = io.StringIO()
|
|
65
|
+
processor = RecordingProcessor()
|
|
66
|
+
emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
|
|
67
|
+
with runtime_config.scope(RuntimeConfig(protocol=protocol)):
|
|
68
|
+
emitter.journal("starting").unwrap()
|
|
69
|
+
assert len(processor.records) == 1
|
|
70
|
+
assert processor.records[0].message == "starting"
|
|
71
|
+
assert processor.records[0].actor_id == actor
|
|
72
|
+
assert "starting" in stream.getvalue()
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("actor", [None, "worker"])
|
|
75
|
+
def test_journal__custom_factory_receives_explicit_actor_at_each_call(self, actor: str | None) -> None:
|
|
76
|
+
calls: list[tuple[str, str | None]] = []
|
|
77
|
+
|
|
78
|
+
def factory(*, message: str, actor_id: str | None) -> JournalCell:
|
|
79
|
+
calls.append((message, actor_id))
|
|
80
|
+
return JournalCell(message=f"{len(calls)}: {message}", actor_id=actor_id)
|
|
81
|
+
|
|
82
|
+
processor = RecordingProcessor()
|
|
83
|
+
emitter = StreamOutputEmitter(stream=io.StringIO(), journal_factory=factory, journal_processor=processor)
|
|
84
|
+
emitter.journal("first", actor_id=actor).unwrap()
|
|
85
|
+
emitter.journal("second", actor_id=actor).unwrap()
|
|
86
|
+
assert calls == [("first", actor), ("second", actor)]
|
|
87
|
+
assert [record.message for record in processor.records] == ["1: first", "2: second"]
|
|
88
|
+
|
|
89
|
+
def test_emit__direct_cells_do_not_invoke_journal_factory(self) -> None:
|
|
90
|
+
def factory(*, message: str, actor_id: str | None) -> JournalCell:
|
|
91
|
+
raise AssertionError("factory must not run")
|
|
92
|
+
|
|
93
|
+
emitter = StreamOutputEmitter(stream=io.StringIO(), journal_factory=factory)
|
|
94
|
+
emitter.emit(
|
|
95
|
+
[EnvironmentErrorCell(error=EnvironmentError(code="failed", message="failure"), is_journal=True)]
|
|
96
|
+
).unwrap()
|
|
97
|
+
|
|
98
|
+
def test_journal__processor_failure_propagates(self) -> None:
|
|
99
|
+
failure = EnvironmentError(code="forward_failed", message="delivery failed")
|
|
100
|
+
stream = io.StringIO()
|
|
101
|
+
emitter = StreamOutputEmitter(stream=stream, journal_processor=RecordingProcessor(Err([failure])))
|
|
102
|
+
assert emitter.journal("event").unwrap_err() == [failure]
|
|
103
|
+
assert not stream.getvalue()
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize("errors", [[], [EnvironmentError(code="custom_failure", message="unavailable")]])
|
|
106
|
+
def test_emit__processor_failure_disables_forwarding_but_preserves_console_routing(
|
|
107
|
+
self, errors: list[EnvironmentError]
|
|
108
|
+
) -> None:
|
|
109
|
+
processor = RecordingProcessor(Err(errors))
|
|
110
|
+
stream = io.StringIO()
|
|
111
|
+
emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
|
|
112
|
+
with runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)):
|
|
113
|
+
assert emitter.journal("failed event").unwrap_err() == errors
|
|
114
|
+
emitter.discard()
|
|
115
|
+
emitter.flush()
|
|
116
|
+
assert not stream.getvalue()
|
|
117
|
+
|
|
118
|
+
emitter.journal("later event").unwrap()
|
|
119
|
+
emitter.emit(
|
|
120
|
+
[
|
|
121
|
+
ContentCell(kind="result", media_type="text/plain", content="result"),
|
|
122
|
+
WarningCell(message="warning", is_journal=True),
|
|
123
|
+
JournalCell(message="last event"),
|
|
124
|
+
]
|
|
125
|
+
).unwrap()
|
|
126
|
+
assert [json.loads(line)["message"] for line in stream.getvalue().splitlines()] == [
|
|
127
|
+
"later event",
|
|
128
|
+
"last event",
|
|
129
|
+
]
|
|
130
|
+
emitter.flush()
|
|
131
|
+
|
|
132
|
+
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
133
|
+
assert len(records) == 4
|
|
134
|
+
assert records[2]["content"] == "result"
|
|
135
|
+
assert records[3]["message"] == "warning"
|
|
136
|
+
assert [record.message for record in processor.records] == ["failed event"]
|
|
137
|
+
|
|
138
|
+
def test_emit__processor_failure_is_local_to_emitter(self) -> None:
|
|
139
|
+
error = EnvironmentError(code="custom_failure", message="unavailable")
|
|
140
|
+
processor = RecordingProcessor(Err([error]))
|
|
141
|
+
first = StreamOutputEmitter(stream=io.StringIO(), journal_processor=processor)
|
|
142
|
+
second = StreamOutputEmitter(stream=io.StringIO(), journal_processor=processor)
|
|
143
|
+
assert first.journal("first").unwrap_err() == [error]
|
|
144
|
+
assert second.journal("second").unwrap_err() == [error]
|
|
145
|
+
assert [record.message for record in processor.records] == ["first", "second"]
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize("journal", [False, True])
|
|
148
|
+
def test_render__override_retains_buffering_and_stream_delivery(
|
|
149
|
+
self, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], journal: bool
|
|
150
|
+
) -> None:
|
|
151
|
+
class CustomOutputEmitter(StreamOutputEmitter):
|
|
152
|
+
def _render(self, cells: list[LogicCell]) -> str:
|
|
153
|
+
return f"Batch of {len(cells)}\n"
|
|
154
|
+
|
|
155
|
+
stream = io.StringIO()
|
|
156
|
+
flush = mocker.spy(stream, "flush")
|
|
157
|
+
emitter = CustomOutputEmitter(stream=stream)
|
|
158
|
+
emitter.emit([JournalCell(message="result", is_journal=journal)]).unwrap()
|
|
159
|
+
assert stream.getvalue() == ("Batch of 1\n" if journal else "")
|
|
160
|
+
emitter.flush()
|
|
161
|
+
emitter.flush()
|
|
162
|
+
assert stream.getvalue() == "Batch of 1\n"
|
|
163
|
+
flush.assert_called_once()
|
|
164
|
+
assert not stream.closed
|
|
165
|
+
assert capsys.readouterr() == ("", "")
|
|
166
|
+
|
|
167
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
168
|
+
def test_emit__custom_stream_receives_journals_then_results_and_warnings(
|
|
169
|
+
self, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], protocol: Protocol
|
|
170
|
+
) -> None:
|
|
171
|
+
stream = io.StringIO()
|
|
172
|
+
flush = mocker.spy(stream, "flush")
|
|
173
|
+
processor = RecordingProcessor()
|
|
174
|
+
emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
|
|
175
|
+
event = JournalCell(message="progress")
|
|
176
|
+
with runtime_config.scope(RuntimeConfig(protocol=protocol)):
|
|
177
|
+
emitter.emit(
|
|
178
|
+
[
|
|
179
|
+
WarningCell(message="warning"),
|
|
180
|
+
ContentCell(kind="result", media_type="text/plain", content="completed"),
|
|
181
|
+
event,
|
|
182
|
+
]
|
|
183
|
+
).unwrap()
|
|
184
|
+
assert "progress" in stream.getvalue()
|
|
185
|
+
assert "completed" not in stream.getvalue()
|
|
186
|
+
assert "warning" not in stream.getvalue()
|
|
187
|
+
assert processor.records == [event.to_journal_record()]
|
|
188
|
+
flush.assert_called_once()
|
|
189
|
+
|
|
190
|
+
emitter.flush()
|
|
191
|
+
delivered = stream.getvalue()
|
|
192
|
+
assert delivered.index("progress") < delivered.index("completed") < delivered.index("warning")
|
|
193
|
+
assert flush.call_count == 2
|
|
194
|
+
emitter.flush()
|
|
195
|
+
assert stream.getvalue() == delivered
|
|
196
|
+
assert flush.call_count == 2
|
|
197
|
+
assert not stream.closed
|
|
198
|
+
assert capsys.readouterr() == ("", "")
|
|
199
|
+
|
|
200
|
+
def test_emit__default_uses_current_stdout_at_delivery(self, mocker: MockerFixture) -> None:
|
|
201
|
+
first = io.StringIO()
|
|
202
|
+
second = io.StringIO()
|
|
203
|
+
mocker.patch.object(sys, "stdout", first)
|
|
204
|
+
emitter = StreamOutputEmitter()
|
|
205
|
+
emitter.emit([ContentCell(kind="result", media_type="text/plain", content="completed")]).unwrap()
|
|
206
|
+
mocker.patch.object(sys, "stdout", second)
|
|
207
|
+
emitter.emit([JournalCell(message="progress")]).unwrap()
|
|
208
|
+
emitter.flush()
|
|
209
|
+
assert not first.getvalue()
|
|
210
|
+
assert second.getvalue().index("progress") < second.getvalue().index("completed")
|
|
211
|
+
|
|
212
|
+
@pytest.mark.parametrize("operation", ["write", "flush"])
|
|
213
|
+
def test_flush__stream_failure_propagates_without_retry(self, mocker: MockerFixture, operation: str) -> None:
|
|
214
|
+
stream = io.StringIO()
|
|
215
|
+
failing_operation = mocker.patch.object(stream, operation, side_effect=OSError("delivery failed"))
|
|
216
|
+
emitter = StreamOutputEmitter(stream=stream)
|
|
217
|
+
emitter.emit([ContentCell(kind="result"), WarningCell(message="warning")]).unwrap()
|
|
218
|
+
with pytest.raises(OSError, match="delivery failed"):
|
|
219
|
+
emitter.flush()
|
|
220
|
+
emitter.flush()
|
|
221
|
+
failing_operation.assert_called_once()
|
|
222
|
+
assert not stream.closed
|
|
223
|
+
|
|
224
|
+
def test_emit__buffers_results_and_warnings_but_delivers_journals(
|
|
225
|
+
self, capsys: pytest.CaptureFixture[str]
|
|
226
|
+
) -> None:
|
|
227
|
+
processor = RecordingProcessor()
|
|
228
|
+
emitter = StreamOutputEmitter(journal_processor=processor)
|
|
229
|
+
first = ContentCell(kind="result", media_type="text/plain", content="first")
|
|
230
|
+
event = JournalCell(message="progress")
|
|
231
|
+
last = first.replace(content="last")
|
|
232
|
+
with runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)):
|
|
233
|
+
emitter.emit(
|
|
234
|
+
[WarningCell(message="warning one"), first, event, last, WarningCell(message="warning two")]
|
|
235
|
+
).unwrap()
|
|
236
|
+
immediate = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
|
|
237
|
+
assert [record["message"] for record in immediate] == ["progress"]
|
|
238
|
+
assert processor.records == [event.to_journal_record()]
|
|
239
|
+
emitter.flush()
|
|
240
|
+
records = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
|
|
241
|
+
assert [record["content"] for record in records[:2]] == ["first", "last"]
|
|
242
|
+
assert [record["message"] for record in records[2:]] == ["warning one", "warning two"]
|
|
243
|
+
emitter.flush()
|
|
244
|
+
assert not capsys.readouterr().out
|
|
245
|
+
assert processor.records == [event.to_journal_record()]
|
|
246
|
+
|
|
247
|
+
@pytest.mark.parametrize("journal", [False, True])
|
|
248
|
+
@pytest.mark.parametrize("warning", [False, True])
|
|
249
|
+
def test_emit__flags_override_subclass_defaults(
|
|
250
|
+
self, capsys: pytest.CaptureFixture[str], journal: bool, warning: bool
|
|
251
|
+
) -> None:
|
|
252
|
+
processor = RecordingProcessor()
|
|
253
|
+
emitter = StreamOutputEmitter(journal_processor=processor)
|
|
254
|
+
cell = JournalCell(message="event", is_journal=journal, is_warning=warning)
|
|
255
|
+
emitter.emit([cell]).unwrap()
|
|
256
|
+
assert processor.records == ([cell.to_journal_record()] if journal else [])
|
|
257
|
+
assert bool(capsys.readouterr().out) == (journal and not warning)
|
|
258
|
+
emitter.flush()
|
|
259
|
+
assert bool(capsys.readouterr().out) == (not journal or warning)
|
|
260
|
+
|
|
261
|
+
def test_emit__journals_semantic_record_and_preserves_console_projection(
|
|
262
|
+
self, capsys: pytest.CaptureFixture[str]
|
|
263
|
+
) -> None:
|
|
264
|
+
cell = EnvironmentErrorCell(
|
|
265
|
+
error=EnvironmentError(code="failed", message="failure", ways_to_fix=["Retry."]), is_journal=True
|
|
266
|
+
)
|
|
267
|
+
processor = RecordingProcessor()
|
|
268
|
+
emitter = StreamOutputEmitter(journal_processor=processor)
|
|
269
|
+
emitter.emit([cell]).unwrap()
|
|
270
|
+
assert len(processor.records) == 1
|
|
271
|
+
assert processor.records[0].message == "failure"
|
|
272
|
+
assert processor.records[0].attributes == {"type": "error", "code": "failed"}
|
|
273
|
+
assert "failure\nWay to fix: Retry." in capsys.readouterr().out
|
|
274
|
+
|
|
275
|
+
def test_emit__failure_stops_delivery_and_never_replays_processor(
|
|
276
|
+
self, capsys: pytest.CaptureFixture[str]
|
|
277
|
+
) -> None:
|
|
278
|
+
error = EnvironmentError(code="forward_failed", message="failed")
|
|
279
|
+
processor = RecordingProcessor(Err([error]))
|
|
280
|
+
emitter = StreamOutputEmitter(journal_processor=processor)
|
|
281
|
+
cell = JournalCell(message="failed event", is_warning=True)
|
|
282
|
+
assert emitter.emit([cell, WarningCell(message="unreached")]).unwrap_err() == [error]
|
|
283
|
+
emitter.flush()
|
|
284
|
+
assert not capsys.readouterr().out
|
|
285
|
+
assert processor.records == [cell.to_journal_record()]
|
|
286
|
+
|
|
287
|
+
@pytest.mark.parametrize(
|
|
288
|
+
("items", "expected"),
|
|
289
|
+
[
|
|
290
|
+
([("café", "second"), (), ("third",)], " TOOL 0/3: café\n TOOL 1/3: second\n TOOL 2/3: third\n"),
|
|
291
|
+
([("only",)], " TOOL 0/1: only\n"),
|
|
292
|
+
([()], ""),
|
|
293
|
+
([], ""),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
def test_flush__preserves_projection_order_and_supplies_complete_batch_context(
|
|
297
|
+
self, capsys: pytest.CaptureFixture[str], items: list[tuple[str, ...]], expected: str
|
|
298
|
+
) -> None:
|
|
299
|
+
emitter = StreamOutputEmitter()
|
|
300
|
+
emitter.emit([PositionedLogicCell(items=values) for values in items]).unwrap()
|
|
301
|
+
with static_config.scope(StaticConfig(tool_label=ToolLabel(" TOOL "))):
|
|
302
|
+
emitter.flush()
|
|
303
|
+
assert capsys.readouterr() == (expected, "")
|
|
304
|
+
|
|
305
|
+
def test_flush__selects_protocol_at_delivery(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
306
|
+
emitter = StreamOutputEmitter()
|
|
307
|
+
emitter.emit([PositionedLogicCell(items=("hidden",))]).unwrap()
|
|
308
|
+
with runtime_config.scope(RuntimeConfig(protocol=Protocol.llm)):
|
|
309
|
+
emitter.flush()
|
|
310
|
+
assert capsys.readouterr() == ("", "")
|
|
311
|
+
|
|
312
|
+
@pytest.mark.parametrize("protocol", [Protocol.human, Protocol.llm])
|
|
313
|
+
@pytest.mark.parametrize("custom_stream", [False, True])
|
|
314
|
+
def test_flush__preserves_unicode_and_protocol_specific_text(
|
|
315
|
+
self, capsys: pytest.CaptureFixture[str], protocol: Protocol, custom_stream: bool
|
|
316
|
+
) -> None:
|
|
317
|
+
stream = io.StringIO()
|
|
318
|
+
emitter = StreamOutputEmitter(stream=stream if custom_stream else None)
|
|
319
|
+
emitter.emit([ContentCell(kind="message", media_type="text/plain", content="café 日本語")]).unwrap()
|
|
320
|
+
with (
|
|
321
|
+
runtime_config.scope(RuntimeConfig(protocol=protocol)),
|
|
322
|
+
static_config.scope(StaticConfig(tool_label=ToolLabel(" TOOL "))),
|
|
323
|
+
):
|
|
324
|
+
emitter.flush()
|
|
325
|
+
captured = capsys.readouterr()
|
|
326
|
+
text = stream.getvalue() if custom_stream else captured.out
|
|
327
|
+
assert (captured.out if custom_stream else stream.getvalue()) == ""
|
|
328
|
+
assert captured.err == ""
|
|
329
|
+
assert "café 日本語" in text
|
|
330
|
+
if protocol == Protocol.human:
|
|
331
|
+
assert text == "café 日本語\n\n"
|
|
332
|
+
else:
|
|
333
|
+
assert text.startswith("-- TOOL -CELL ")
|
|
334
|
+
assert text.endswith(" END--\n")
|
|
335
|
+
|
|
336
|
+
@pytest.mark.parametrize("label", ["FIRST", "SECOND"])
|
|
337
|
+
def test_flush__automation_preserves_cells_and_ignores_label(self, label: str) -> None:
|
|
338
|
+
stream = io.StringIO()
|
|
339
|
+
emitter = StreamOutputEmitter(stream=stream)
|
|
340
|
+
cell = ContentCell(kind="message", media_type="text/plain", content="café 日本語", meta={"type": "message"})
|
|
341
|
+
original = cell.model_dump()
|
|
342
|
+
with (
|
|
343
|
+
runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)),
|
|
344
|
+
static_config.scope(StaticConfig(tool_label=ToolLabel(label))),
|
|
345
|
+
):
|
|
346
|
+
emitter.emit([cell]).unwrap()
|
|
347
|
+
emitter.flush()
|
|
348
|
+
emitter.emit([cell]).unwrap()
|
|
349
|
+
emitter.flush()
|
|
350
|
+
first, second = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
351
|
+
assert first.pop("id") != second.pop("id")
|
|
352
|
+
assert first == second == {"type": "message", "content": "café 日本語"}
|
|
353
|
+
assert cell.model_dump() == original
|
|
354
|
+
assert stream.getvalue().endswith("\n")
|
|
355
|
+
|
|
356
|
+
def test_flush__preserves_error_order_and_guidance(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
357
|
+
emitter = StreamOutputEmitter()
|
|
358
|
+
emitter.emit(
|
|
359
|
+
[
|
|
360
|
+
EnvironmentErrorCell(error=EnvironmentError(code="first", message="First", ways_to_fix=["Retry."])),
|
|
361
|
+
EnvironmentErrorCell(error=EnvironmentError(code="second", message="Second")),
|
|
362
|
+
]
|
|
363
|
+
).unwrap()
|
|
364
|
+
with runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)):
|
|
365
|
+
emitter.flush()
|
|
366
|
+
records = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
|
|
367
|
+
assert records[0].pop("id") != records[1].pop("id")
|
|
368
|
+
assert records == [
|
|
369
|
+
{"type": "error", "code": "first", "content": "First\nWay to fix: Retry."},
|
|
370
|
+
{"type": "error", "code": "second", "content": "Second"},
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
def test_flush__missing_initialization_writes_nothing(
|
|
374
|
+
self, isolated_settings: None, capsys: pytest.CaptureFixture[str]
|
|
375
|
+
) -> None:
|
|
376
|
+
emitter = StreamOutputEmitter()
|
|
377
|
+
emitter.emit([ContentCell(kind="message")]).unwrap()
|
|
378
|
+
with pytest.raises(ContextValueNotSet) as caught:
|
|
379
|
+
emitter.flush()
|
|
380
|
+
assert caught.value.details == {"name": "static_config"}
|
|
381
|
+
assert capsys.readouterr() == ("", "")
|
|
382
|
+
|
|
383
|
+
@pytest.mark.parametrize(
|
|
384
|
+
("last_output", "exception_type"),
|
|
385
|
+
[(ValueError("bad render"), ValueError), (b"\xff", UnicodeDecodeError)],
|
|
386
|
+
)
|
|
387
|
+
def test_flush__render_or_decode_failure_writes_nothing(
|
|
388
|
+
self,
|
|
389
|
+
mocker: MockerFixture,
|
|
390
|
+
capsys: pytest.CaptureFixture[str],
|
|
391
|
+
last_output: bytes | Exception,
|
|
392
|
+
exception_type: type[Exception],
|
|
393
|
+
) -> None:
|
|
394
|
+
emitter = StreamOutputEmitter()
|
|
395
|
+
emitter.emit([ContentCell(kind="first"), ContentCell(kind="last")]).unwrap()
|
|
396
|
+
mocker.patch.object(HumanOutputCell, "render", side_effect=[b"first", last_output])
|
|
397
|
+
with pytest.raises(exception_type):
|
|
398
|
+
emitter.flush()
|
|
399
|
+
assert not capsys.readouterr().out
|
|
400
|
+
emitter.flush()
|
|
401
|
+
assert not capsys.readouterr().out
|
|
402
|
+
|
|
403
|
+
def test_emit__flushes_live_journal_stream(self, mocker: MockerFixture) -> None:
|
|
404
|
+
flush = mocker.spy(sys.stdout, "flush")
|
|
405
|
+
StreamOutputEmitter().emit([JournalCell(message="live")]).unwrap()
|
|
406
|
+
flush.assert_called_once()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
import pytest
|
|
8
|
+
from pytest_mock import MockerFixture
|
|
9
|
+
|
|
10
|
+
from llm_tool_cli.core.errors import EnvironmentError
|
|
11
|
+
from llm_tool_cli.output.errors import JournalCommandConfigInvalid, JournalCommandFailed
|
|
12
|
+
from llm_tool_cli.output.journal import CommandJournalProcessor, JournalConfig, NoopJournalProcessor
|
|
13
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
14
|
+
from llm_tool_cli.protocol.logic_cells import EnvironmentErrorCell
|
|
15
|
+
from llm_tool_cli.protocol.logic_cells.journal import JournalCell
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestNoopJournalProcessor:
|
|
19
|
+
def test_process__accepts_record_without_output(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
20
|
+
assert NoopJournalProcessor().process(JournalRecord(message="event")).is_ok()
|
|
21
|
+
assert capsys.readouterr() == ("", "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestCommandJournalProcessor:
|
|
25
|
+
def test_process__preserves_literal_and_multiline_arguments(self, tmp_path: Path) -> None:
|
|
26
|
+
target = tmp_path / "arguments.json"
|
|
27
|
+
timestamp = datetime.datetime.now(datetime.UTC)
|
|
28
|
+
cell = JournalCell(message=" first\nsecond\rthird ", timestamp=timestamp)
|
|
29
|
+
processor = CommandJournalProcessor(
|
|
30
|
+
[
|
|
31
|
+
sys.executable,
|
|
32
|
+
"-c",
|
|
33
|
+
"import json, pathlib, sys; pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))",
|
|
34
|
+
str(target),
|
|
35
|
+
"{message}",
|
|
36
|
+
"{timestamp}",
|
|
37
|
+
"{actor_id}",
|
|
38
|
+
"literal:{message}",
|
|
39
|
+
"{message",
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
processor.process(cell.to_journal_record()).unwrap()
|
|
43
|
+
assert json.loads(target.read_text()) == [
|
|
44
|
+
"first\nsecond\rthird",
|
|
45
|
+
timestamp.isoformat(),
|
|
46
|
+
"",
|
|
47
|
+
"literal:{message}",
|
|
48
|
+
"{message",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def test_process__supports_error_cell_fields(self, tmp_path: Path) -> None:
|
|
52
|
+
target = tmp_path / "code.txt"
|
|
53
|
+
processor = CommandJournalProcessor(
|
|
54
|
+
[
|
|
55
|
+
sys.executable,
|
|
56
|
+
"-c",
|
|
57
|
+
"import pathlib, sys; pathlib.Path(sys.argv[1]).write_text(sys.argv[2])",
|
|
58
|
+
str(target),
|
|
59
|
+
"{code}",
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
processor.process(
|
|
63
|
+
EnvironmentErrorCell(error=EnvironmentError(code="failed", message="failure")).to_journal_record()
|
|
64
|
+
).unwrap()
|
|
65
|
+
assert target.read_text() == "failed"
|
|
66
|
+
|
|
67
|
+
def test_process__empty_command_does_not_execute(self, mocker: MockerFixture) -> None:
|
|
68
|
+
run = mocker.patch("llm_tool_cli.output.journal.subprocess.run")
|
|
69
|
+
failures = CommandJournalProcessor([]).process(JournalRecord(message="test")).unwrap_err()
|
|
70
|
+
assert isinstance(failures[0], JournalCommandConfigInvalid)
|
|
71
|
+
run.assert_not_called()
|
|
72
|
+
|
|
73
|
+
def test_process__missing_executable_is_expected_error(self, tmp_path: Path) -> None:
|
|
74
|
+
command = [str(tmp_path / "missing")]
|
|
75
|
+
error = CommandJournalProcessor(command).process(JournalRecord(message="test")).unwrap_err()[0]
|
|
76
|
+
assert isinstance(error, JournalCommandFailed)
|
|
77
|
+
assert error.command == command
|
|
78
|
+
assert error.returncode is None
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("stderr", ["", "explanation"])
|
|
81
|
+
def test_process__nonzero_exit_preserves_context(self, stderr: str) -> None:
|
|
82
|
+
command = [sys.executable, "-c", "import sys; sys.stderr.write(sys.argv[1]); sys.exit(7)", stderr]
|
|
83
|
+
error = CommandJournalProcessor(command).process(JournalRecord(message="test")).unwrap_err()[0]
|
|
84
|
+
assert isinstance(error, JournalCommandFailed)
|
|
85
|
+
assert error.returncode == 7
|
|
86
|
+
assert error.command == command
|
|
87
|
+
assert error.details == ("exit code 7; stderr: explanation" if stderr else "exit code 7")
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
"attributes, expected",
|
|
91
|
+
[
|
|
92
|
+
({}, ""),
|
|
93
|
+
({"task_id": None}, ""),
|
|
94
|
+
({"task_id": "T-1"}, "T-1"),
|
|
95
|
+
({"task_id": 0}, "0"),
|
|
96
|
+
({"task_id": False}, "False"),
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
def test_process__substitutes_record_fields(
|
|
100
|
+
self, tmp_path: Path, attributes: dict[str, pydantic.JsonValue], expected: str
|
|
101
|
+
) -> None:
|
|
102
|
+
target = tmp_path / "argument.txt"
|
|
103
|
+
processor = CommandJournalProcessor(
|
|
104
|
+
[
|
|
105
|
+
sys.executable,
|
|
106
|
+
"-c",
|
|
107
|
+
"import pathlib, sys; pathlib.Path(sys.argv[1]).write_text(sys.argv[2])",
|
|
108
|
+
str(target),
|
|
109
|
+
"{task_id}",
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
processor.process(JournalRecord(message="event", attributes=attributes)).unwrap()
|
|
113
|
+
assert target.read_text() == expected
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestJournalConfig:
|
|
117
|
+
@pytest.mark.parametrize("command", [[], ["tool", 1]])
|
|
118
|
+
def test_validation__rejects_invalid_command(self, command: list[object]) -> None:
|
|
119
|
+
with pytest.raises(pydantic.ValidationError):
|
|
120
|
+
JournalConfig.model_validate({"cmd": command})
|
|
121
|
+
|
|
122
|
+
def test_processor__defaults_to_noop(self) -> None:
|
|
123
|
+
assert JournalConfig().processor().process(JournalRecord(message="event")).is_ok()
|
|
124
|
+
|
|
125
|
+
def test_processor__uses_record_fields_without_declarations(self, tmp_path: Path) -> None:
|
|
126
|
+
target = tmp_path / "arguments.json"
|
|
127
|
+
config = JournalConfig(
|
|
128
|
+
cmd=[
|
|
129
|
+
sys.executable,
|
|
130
|
+
"-c",
|
|
131
|
+
"import json, pathlib, sys; pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))",
|
|
132
|
+
str(target),
|
|
133
|
+
"{missing}",
|
|
134
|
+
"{message}",
|
|
135
|
+
"{custom_field}",
|
|
136
|
+
"{attributes}",
|
|
137
|
+
"after",
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
config.processor().process(JournalRecord(message="event", attributes={"custom_field": "value"})).unwrap()
|
|
141
|
+
assert json.loads(target.read_text()) == ["", "event", "value", "", "after"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Lexical identifiers, filesystem resolution, and project-root containment.
|
|
2
|
+
|
|
3
|
+
Lexical normalization is independent of the filesystem. Application-specific
|
|
4
|
+
syntax belongs to callers. ``paths.errors`` exposes public path failures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.paths.enumeration import enumerate_files
|
|
8
|
+
from llm_tool_cli.paths.filesystem import (
|
|
9
|
+
PathInput,
|
|
10
|
+
ProjectConfigPath,
|
|
11
|
+
ProjectRootPath,
|
|
12
|
+
RelativeProjectPath,
|
|
13
|
+
ResolvedProjectPath,
|
|
14
|
+
normalize_path,
|
|
15
|
+
project_path_id_from_filesystem,
|
|
16
|
+
project_path_id_from_resolved,
|
|
17
|
+
resolve_inside_project,
|
|
18
|
+
resolve_project_path,
|
|
19
|
+
resolve_project_root,
|
|
20
|
+
resolve_root_anchored_path,
|
|
21
|
+
)
|
|
22
|
+
from llm_tool_cli.paths.globs import FileGlobPattern, glob_capture_names, match_glob
|
|
23
|
+
from llm_tool_cli.paths.normalization import (
|
|
24
|
+
ProjectPathId,
|
|
25
|
+
is_project_path_id,
|
|
26
|
+
normalize_project_path_id,
|
|
27
|
+
project_path_parts,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"FileGlobPattern",
|
|
32
|
+
"PathInput",
|
|
33
|
+
"ProjectConfigPath",
|
|
34
|
+
"ProjectPathId",
|
|
35
|
+
"ProjectRootPath",
|
|
36
|
+
"RelativeProjectPath",
|
|
37
|
+
"ResolvedProjectPath",
|
|
38
|
+
"enumerate_files",
|
|
39
|
+
"glob_capture_names",
|
|
40
|
+
"is_project_path_id",
|
|
41
|
+
"match_glob",
|
|
42
|
+
"normalize_path",
|
|
43
|
+
"normalize_project_path_id",
|
|
44
|
+
"project_path_id_from_filesystem",
|
|
45
|
+
"project_path_id_from_resolved",
|
|
46
|
+
"project_path_parts",
|
|
47
|
+
"resolve_inside_project",
|
|
48
|
+
"resolve_project_path",
|
|
49
|
+
"resolve_project_root",
|
|
50
|
+
"resolve_root_anchored_path",
|
|
51
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Deterministic project-file discovery without symlink traversal."""
|
|
2
|
+
|
|
3
|
+
import stat
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
|
|
8
|
+
from llm_tool_cli.paths.errors import FilesUnreadable
|
|
9
|
+
from llm_tool_cli.paths.filesystem import (
|
|
10
|
+
ResolvedProjectPath,
|
|
11
|
+
project_path_id_from_resolved,
|
|
12
|
+
resolve_project_root,
|
|
13
|
+
)
|
|
14
|
+
from llm_tool_cli.paths.globs import FileGlobPattern, _compile_glob, _pattern_parts
|
|
15
|
+
from llm_tool_cli.paths.normalization import ProjectPathId
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _glob_files(path: Path, parts: tuple[str, ...]) -> Iterator[Path]: # noqa: CCR001
|
|
19
|
+
try:
|
|
20
|
+
mode = path.lstat().st_mode
|
|
21
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
if not parts:
|
|
25
|
+
if stat.S_ISREG(mode):
|
|
26
|
+
yield path
|
|
27
|
+
return
|
|
28
|
+
if not stat.S_ISDIR(mode):
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
segment, *remaining = parts
|
|
32
|
+
compiled, recursive = _compile_glob(FileGlobPattern(segment)).unwrap()
|
|
33
|
+
if recursive:
|
|
34
|
+
compiled, _ = _compile_glob(FileGlobPattern("/".join(parts))).unwrap()
|
|
35
|
+
for entry in _walk_files(path):
|
|
36
|
+
if compiled.fullmatch(entry.relative_to(path).as_posix()):
|
|
37
|
+
yield entry
|
|
38
|
+
return
|
|
39
|
+
for entry in sorted(path.iterdir()):
|
|
40
|
+
if compiled.fullmatch(entry.name):
|
|
41
|
+
yield from _glob_files(entry, tuple(remaining))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _walk_files(path: Path) -> Iterator[Path]:
|
|
45
|
+
"""Walk regular files without following links, including broken links."""
|
|
46
|
+
try:
|
|
47
|
+
mode = path.lstat().st_mode
|
|
48
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
49
|
+
return
|
|
50
|
+
if stat.S_ISREG(mode):
|
|
51
|
+
yield path
|
|
52
|
+
elif stat.S_ISDIR(mode):
|
|
53
|
+
for entry in sorted(path.iterdir()):
|
|
54
|
+
yield from _walk_files(entry)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@unwrap_to_error
|
|
58
|
+
def enumerate_files(root: Path, pattern: FileGlobPattern = FileGlobPattern("**/*")) -> Result[list[ProjectPathId]]:
|
|
59
|
+
"""Find regular files matching a project-relative or root-anchored glob.
|
|
60
|
+
|
|
61
|
+
Support absolute and home-relative patterns through shared path resolution.
|
|
62
|
+
Include hidden entries, skip all discovered symlinks, and reject symlinks
|
|
63
|
+
in explicit pattern components. Return unique ids in path-component order.
|
|
64
|
+
"""
|
|
65
|
+
project_root = resolve_project_root(root).unwrap()
|
|
66
|
+
parts = _pattern_parts(pattern, project_root).unwrap()
|
|
67
|
+
_compile_glob(FileGlobPattern("/".join(parts))).unwrap()
|
|
68
|
+
try:
|
|
69
|
+
paths = sorted(set(_glob_files(project_root, parts)))
|
|
70
|
+
except OSError as error:
|
|
71
|
+
return Err([FilesUnreadable(path=str(project_root), reason=str(error)).with_cause(error)])
|
|
72
|
+
return Ok([project_path_id_from_resolved(ResolvedProjectPath(path), project_root) for path in paths])
|