pi-durable-python 1.3.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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Portable execution-environment capability.
|
|
2
|
+
|
|
3
|
+
This module publishes the ``FileSystem`` and ``Shell`` capabilities, their result and error
|
|
4
|
+
shapes, and the decoding, line-scanning, watching, and listing helpers. The POSIX backend lives in
|
|
5
|
+
:mod:`pi_durable.env.posix`; the JSONL backend depends only on the filesystem capability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
ClassVar,
|
|
16
|
+
Generic,
|
|
17
|
+
Literal,
|
|
18
|
+
Protocol,
|
|
19
|
+
TypeAlias,
|
|
20
|
+
TypeVar,
|
|
21
|
+
runtime_checkable,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ..context import Context
|
|
25
|
+
from .decode import StreamDecoder, range_decoder, starts_with_bom
|
|
26
|
+
from .line_scan import LineScan, LineScanner
|
|
27
|
+
|
|
28
|
+
TValue = TypeVar("TValue")
|
|
29
|
+
TError = TypeVar("TError")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Ok(Generic[TValue]):
|
|
34
|
+
"""A successful result."""
|
|
35
|
+
|
|
36
|
+
value: TValue
|
|
37
|
+
ok: ClassVar[Literal[True]] = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Err(Generic[TError]):
|
|
42
|
+
"""A failed result carrying the expected error."""
|
|
43
|
+
|
|
44
|
+
error: TError
|
|
45
|
+
ok: ClassVar[Literal[False]] = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Result: TypeAlias = Ok[TValue] | Err[TError]
|
|
49
|
+
"""The outcome of a fallible operation; expected failures are returned instead of thrown."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ok(value: TValue) -> Result[TValue, TError]:
|
|
53
|
+
return Ok(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def err(error: TError) -> Result[TValue, TError]:
|
|
57
|
+
return Err(error)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_or_throw(result: Result[TValue, Any]) -> TValue:
|
|
61
|
+
if isinstance(result, Err):
|
|
62
|
+
error = result.error
|
|
63
|
+
if isinstance(error, BaseException):
|
|
64
|
+
raise error
|
|
65
|
+
raise RuntimeError(to_error(error))
|
|
66
|
+
return result.value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_or_undefined(result: Result[TValue, Any]) -> TValue | None:
|
|
70
|
+
return None if isinstance(result, Err) else result.value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def to_error(error: object) -> Exception:
|
|
74
|
+
"""Normalize any thrown value to an exception without throwing while formatting it."""
|
|
75
|
+
|
|
76
|
+
if isinstance(error, Exception):
|
|
77
|
+
return error
|
|
78
|
+
if isinstance(error, str):
|
|
79
|
+
return RuntimeError(error)
|
|
80
|
+
try:
|
|
81
|
+
return RuntimeError(json.dumps(error))
|
|
82
|
+
except Exception:
|
|
83
|
+
return RuntimeError(str(error))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
FileKind: TypeAlias = Literal["file", "directory", "symlink"]
|
|
87
|
+
|
|
88
|
+
FileErrorCode: TypeAlias = Literal[
|
|
89
|
+
"aborted",
|
|
90
|
+
"not_found",
|
|
91
|
+
"permission_denied",
|
|
92
|
+
"not_directory",
|
|
93
|
+
"is_directory",
|
|
94
|
+
"invalid",
|
|
95
|
+
"not_supported",
|
|
96
|
+
"unknown",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FileError(Exception):
|
|
101
|
+
"""A filesystem operation failed. ``code`` classifies the expected failure."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
code: FileErrorCode,
|
|
106
|
+
message: str,
|
|
107
|
+
path: str | None = None,
|
|
108
|
+
cause: BaseException | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
super().__init__(message)
|
|
111
|
+
self.name = "FileError"
|
|
112
|
+
self.code = code
|
|
113
|
+
self.path = path
|
|
114
|
+
if cause is not None:
|
|
115
|
+
self.__cause__ = cause
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
ExecutionErrorCode: TypeAlias = Literal[
|
|
119
|
+
"aborted",
|
|
120
|
+
"timeout",
|
|
121
|
+
"shell_unavailable",
|
|
122
|
+
"spawn_error",
|
|
123
|
+
"callback_error",
|
|
124
|
+
"unknown",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ExecutionError(Exception):
|
|
129
|
+
"""A shell execution failed. ``code`` classifies the expected failure."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
code: ExecutionErrorCode,
|
|
134
|
+
message: str,
|
|
135
|
+
cause: BaseException | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
super().__init__(message)
|
|
138
|
+
self.name = "ExecutionError"
|
|
139
|
+
self.code = code
|
|
140
|
+
#: Spill file of a command that timed out or was aborted after its output crossed the thresholds.
|
|
141
|
+
self.spill_path: str | None = None
|
|
142
|
+
if cause is not None:
|
|
143
|
+
self.__cause__ = cause
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class FileInfo(dict[str, Any]):
|
|
147
|
+
"""Metadata of one file, directory, or symbolic link.
|
|
148
|
+
|
|
149
|
+
A plain ``dict`` subclass exposing the upstream fields: ``name``, ``path``, ``kind``,
|
|
150
|
+
``size``, and ``mtimeMs``.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TextLine(dict[str, Any]):
|
|
155
|
+
"""One line of text and whether it ended with a terminator."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TextLineReader(Protocol):
|
|
159
|
+
async def read_line(self, context: Context) -> Result[TextLine | None, FileError]: ...
|
|
160
|
+
|
|
161
|
+
async def close(self, context: Context) -> None: ...
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class BinaryReader(Protocol):
|
|
165
|
+
"""Positional reads from one opened regular file; all calls see the same file if renamed."""
|
|
166
|
+
|
|
167
|
+
async def info(self, context: Context) -> Result[FileInfo, FileError]: ...
|
|
168
|
+
|
|
169
|
+
async def read(self, offset: int, length: int, context: Context) -> Result[bytes, FileError]: ...
|
|
170
|
+
|
|
171
|
+
async def scan_lines(self, options: dict[str, Any], context: Context) -> Result[LineScan, FileError]: ...
|
|
172
|
+
|
|
173
|
+
async def close(self, context: Context) -> None: ...
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class WatchTarget(dict[str, Any]):
|
|
177
|
+
"""A file or directory to watch."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class WatchPaths(dict[str, Any]):
|
|
181
|
+
paths: list[str]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class WatchOverflow(dict[str, Any]):
|
|
185
|
+
overflow: Literal[True]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class WatchError(dict[str, Any]):
|
|
189
|
+
error: FileError
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
WatchChange: TypeAlias = WatchPaths | WatchOverflow | WatchError
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class FileWatcher(Protocol):
|
|
196
|
+
mode: Literal["native", "polling"]
|
|
197
|
+
|
|
198
|
+
async def close(self, context: Context) -> None: ...
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class DirPage(dict[str, Any]):
|
|
202
|
+
entries: list[FileInfo]
|
|
203
|
+
done: bool
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class DirReader(Protocol):
|
|
207
|
+
async def next(self, max_entries: int, context: Context) -> Result[DirPage, FileError]: ...
|
|
208
|
+
|
|
209
|
+
async def close(self, context: Context) -> None: ...
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ListFilesOptions(dict[str, Any]):
|
|
213
|
+
"""Options for the optional ``FileSystem.list_files``.
|
|
214
|
+
|
|
215
|
+
The POSIX backend reads the ``.gitignore`` chain and ``.git/info/exclude``; global Git
|
|
216
|
+
excludes are not read.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class FileListEntry(dict[str, Any]):
|
|
221
|
+
"""One entry of a :class:`FileListing`."""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class FileListing(dict[str, Any]):
|
|
225
|
+
"""Files, directories, and symbolic links below a directory."""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ShellSpillOptions(dict[str, Any]):
|
|
229
|
+
"""Spill the complete output to a temporary file once it exceeds either threshold."""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ShellExecResult(dict[str, Any]):
|
|
233
|
+
"""The exit code and, when it spilled, the file holding the complete raw output."""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ShellExecOptions(dict[str, Any]):
|
|
237
|
+
"""Options for one shell command."""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ShellOutputWindow(dict[str, Any]):
|
|
241
|
+
"""The tail of the combined output a caller keeps, and how often it samples it."""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, slots=True)
|
|
245
|
+
class ShellOutputSkip:
|
|
246
|
+
"""Output omitted immediately before a chunk."""
|
|
247
|
+
|
|
248
|
+
bytes: int
|
|
249
|
+
newlines: int
|
|
250
|
+
ends_with_newline: bool
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True, slots=True)
|
|
254
|
+
class ShellOutputInfo:
|
|
255
|
+
"""Which stream a chunk came from, and what was omitted immediately before it."""
|
|
256
|
+
|
|
257
|
+
stream: Literal["stdout", "stderr"]
|
|
258
|
+
skipped: ShellOutputSkip | None = None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Shell(Protocol):
|
|
262
|
+
"""Run commands through the environment's shell or as an argv array."""
|
|
263
|
+
|
|
264
|
+
async def exec(
|
|
265
|
+
self,
|
|
266
|
+
command: str | Sequence[str],
|
|
267
|
+
options: ShellExecOptions | None,
|
|
268
|
+
context: Context,
|
|
269
|
+
) -> Result[ShellExecResult, ExecutionError]: ...
|
|
270
|
+
|
|
271
|
+
async def cleanup(self, context: Context) -> None: ...
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@runtime_checkable
|
|
275
|
+
class FileSystem(Protocol):
|
|
276
|
+
"""Portable filesystem capability. Operations return failures rather than throwing."""
|
|
277
|
+
|
|
278
|
+
id: str
|
|
279
|
+
cwd: str
|
|
280
|
+
|
|
281
|
+
async def absolute_path(self, path: str, context: Context) -> Result[str, FileError]: ...
|
|
282
|
+
|
|
283
|
+
async def join_path(self, parts: Sequence[str], context: Context) -> Result[str, FileError]: ...
|
|
284
|
+
|
|
285
|
+
async def read_text_file(self, path: str, context: Context) -> Result[str, FileError]: ...
|
|
286
|
+
|
|
287
|
+
async def open_text_line_reader(
|
|
288
|
+
self, path: str, context: Context
|
|
289
|
+
) -> Result[TextLineReader, FileError]: ...
|
|
290
|
+
|
|
291
|
+
async def read_text_lines(
|
|
292
|
+
self,
|
|
293
|
+
path: str,
|
|
294
|
+
options: dict[str, Any] | None,
|
|
295
|
+
context: Context,
|
|
296
|
+
) -> Result[list[str], FileError]: ...
|
|
297
|
+
|
|
298
|
+
async def read_binary_file(self, path: str, context: Context) -> Result[bytes, FileError]: ...
|
|
299
|
+
|
|
300
|
+
async def open_binary_reader(
|
|
301
|
+
self, path: str, options: dict[str, Any] | None, context: Context
|
|
302
|
+
) -> Result[BinaryReader, FileError]: ...
|
|
303
|
+
|
|
304
|
+
async def write_file(
|
|
305
|
+
self, path: str, content: str | bytes, context: Context
|
|
306
|
+
) -> Result[None, FileError]: ...
|
|
307
|
+
|
|
308
|
+
async def append_file(
|
|
309
|
+
self, path: str, content: str | bytes, context: Context
|
|
310
|
+
) -> Result[None, FileError]: ...
|
|
311
|
+
|
|
312
|
+
async def truncate_file(self, path: str, size: int, context: Context) -> Result[None, FileError]: ...
|
|
313
|
+
|
|
314
|
+
async def flush_file(self, path: str, context: Context) -> Result[None, FileError]: ...
|
|
315
|
+
|
|
316
|
+
async def rename_file(
|
|
317
|
+
self, source_path: str, destination_path: str, context: Context
|
|
318
|
+
) -> Result[None, FileError]: ...
|
|
319
|
+
|
|
320
|
+
async def file_info(self, path: str, context: Context) -> Result[FileInfo, FileError]: ...
|
|
321
|
+
|
|
322
|
+
async def list_dir(self, path: str, context: Context) -> Result[list[FileInfo], FileError]: ...
|
|
323
|
+
|
|
324
|
+
async def list_files(
|
|
325
|
+
self,
|
|
326
|
+
path: str,
|
|
327
|
+
options: ListFilesOptions | None,
|
|
328
|
+
context: Context,
|
|
329
|
+
) -> Result[FileListing, FileError]: ...
|
|
330
|
+
|
|
331
|
+
async def open_dir_reader(self, path: str, context: Context) -> Result[DirReader, FileError]: ...
|
|
332
|
+
|
|
333
|
+
async def watch(
|
|
334
|
+
self,
|
|
335
|
+
targets: Sequence[WatchTarget],
|
|
336
|
+
on_change: Callable[[WatchChange], None],
|
|
337
|
+
context: Context,
|
|
338
|
+
) -> Result[FileWatcher, FileError]: ...
|
|
339
|
+
|
|
340
|
+
async def canonical_path(self, path: str, context: Context) -> Result[str, FileError]: ...
|
|
341
|
+
|
|
342
|
+
async def exists(self, path: str, context: Context) -> Result[bool, FileError]: ...
|
|
343
|
+
|
|
344
|
+
async def create_dir(
|
|
345
|
+
self, path: str, options: dict[str, Any] | None, context: Context
|
|
346
|
+
) -> Result[None, FileError]: ...
|
|
347
|
+
|
|
348
|
+
async def remove(
|
|
349
|
+
self, path: str, options: dict[str, Any] | None, context: Context
|
|
350
|
+
) -> Result[None, FileError]: ...
|
|
351
|
+
|
|
352
|
+
async def create_temp_dir(self, prefix: str | None, context: Context) -> Result[str, FileError]: ...
|
|
353
|
+
|
|
354
|
+
async def create_temp_file(
|
|
355
|
+
self, options: dict[str, Any] | None, context: Context
|
|
356
|
+
) -> Result[str, FileError]: ...
|
|
357
|
+
|
|
358
|
+
async def cleanup(self, context: Context) -> None: ...
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class ExecutionEnv(FileSystem, Shell, Protocol):
|
|
362
|
+
"""A portable filesystem plus shell execution."""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
__all__ = [
|
|
366
|
+
"BinaryReader",
|
|
367
|
+
"DirPage",
|
|
368
|
+
"DirReader",
|
|
369
|
+
"Err",
|
|
370
|
+
"ExecutionEnv",
|
|
371
|
+
"ExecutionError",
|
|
372
|
+
"ExecutionErrorCode",
|
|
373
|
+
"FileError",
|
|
374
|
+
"FileErrorCode",
|
|
375
|
+
"FileInfo",
|
|
376
|
+
"FileKind",
|
|
377
|
+
"FileListEntry",
|
|
378
|
+
"FileListing",
|
|
379
|
+
"FileSystem",
|
|
380
|
+
"FileWatcher",
|
|
381
|
+
"LineScan",
|
|
382
|
+
"LineScanner",
|
|
383
|
+
"ListFilesOptions",
|
|
384
|
+
"Ok",
|
|
385
|
+
"Result",
|
|
386
|
+
"Shell",
|
|
387
|
+
"ShellExecOptions",
|
|
388
|
+
"ShellExecResult",
|
|
389
|
+
"ShellOutputInfo",
|
|
390
|
+
"ShellOutputSkip",
|
|
391
|
+
"ShellOutputWindow",
|
|
392
|
+
"ShellSpillOptions",
|
|
393
|
+
"StreamDecoder",
|
|
394
|
+
"TextLine",
|
|
395
|
+
"TextLineReader",
|
|
396
|
+
"WatchChange",
|
|
397
|
+
"WatchError",
|
|
398
|
+
"WatchOverflow",
|
|
399
|
+
"WatchPaths",
|
|
400
|
+
"WatchTarget",
|
|
401
|
+
"err",
|
|
402
|
+
"get_or_throw",
|
|
403
|
+
"get_or_undefined",
|
|
404
|
+
"ok",
|
|
405
|
+
"range_decoder",
|
|
406
|
+
"starts_with_bom",
|
|
407
|
+
"to_error",
|
|
408
|
+
]
|
pi_durable/env/decode.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Streaming UTF-8 decoding that matches decoding a whole byte range at once.
|
|
2
|
+
|
|
3
|
+
Python's incremental ``utf-8`` decoder keeps a byte-order mark, so the port mirrors the upstream
|
|
4
|
+
WHATWG ``TextDecoder`` configuration: the range decoder never strips a mark, and
|
|
5
|
+
:class:`StreamDecoder` drops one only when it starts the stream.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import codecs
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
_BOM: Final = b"\xef\xbb\xbf"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def range_decoder() -> codecs.IncrementalDecoder:
|
|
17
|
+
"""A decoder for one byte range; the caller strips a leading mark when it starts the file."""
|
|
18
|
+
|
|
19
|
+
return codecs.getincrementaldecoder("utf-8")("replace")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def starts_with_bom(first_bytes: bytes) -> bool:
|
|
23
|
+
"""Whether decoding the whole input drops its first three bytes as a byte-order mark."""
|
|
24
|
+
|
|
25
|
+
return first_bytes[:3] == _BOM
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StreamDecoder:
|
|
29
|
+
"""Decode one stream chunk by chunk exactly like decoding all of it at once."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_decoder", "_started")
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._decoder = range_decoder()
|
|
35
|
+
self._started = False
|
|
36
|
+
|
|
37
|
+
def decode(self, data: bytes | None = None) -> str:
|
|
38
|
+
"""Text for ``data``, holding back an incomplete character; no ``data`` flushes the stream."""
|
|
39
|
+
|
|
40
|
+
text = self._decoder.decode(b"", True) if data is None else self._decoder.decode(data, False)
|
|
41
|
+
if self._started or text == "":
|
|
42
|
+
return text
|
|
43
|
+
self._started = True
|
|
44
|
+
# U+FEFF encodes only as EF BB BF, so a leading U+FEFF is exactly a mark at the stream start.
|
|
45
|
+
return text[1:] if text.startswith("\ufeff") else text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["StreamDecoder", "range_decoder", "starts_with_bom"]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Find where selected lines of a file are, in bounded memory.
|
|
2
|
+
|
|
3
|
+
Port of the v1.3.0 ``env/line-scan`` helper. A caller feeds a file's bytes in order; the finished
|
|
4
|
+
scan gives byte offsets and decoded sizes for the selected line range.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import codecs
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..truncate import utf8_byte_length
|
|
14
|
+
from .decode import range_decoder, starts_with_bom
|
|
15
|
+
|
|
16
|
+
_NEWLINE = 0x0A
|
|
17
|
+
_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LineScan(dict[str, Any]):
|
|
21
|
+
"""Where lines of a file are, as :meth:`BinaryReader.scan_lines` found them."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LineScanner:
|
|
25
|
+
"""Compute a :class:`LineScan` from a file's bytes fed in order.
|
|
26
|
+
|
|
27
|
+
``start_line`` and ``end_line`` must be non-negative integers with ``end_line > start_line``;
|
|
28
|
+
an absent ``end_line`` means to the end of the file.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = (
|
|
32
|
+
"_bom",
|
|
33
|
+
"_end",
|
|
34
|
+
"_end_line",
|
|
35
|
+
"_first_line",
|
|
36
|
+
"_first_line_bytes",
|
|
37
|
+
"_first_line_end",
|
|
38
|
+
"_head",
|
|
39
|
+
"_last_line_start",
|
|
40
|
+
"_line_start",
|
|
41
|
+
"_newlines",
|
|
42
|
+
"_position",
|
|
43
|
+
"_selected_bytes",
|
|
44
|
+
"_selection",
|
|
45
|
+
"_start",
|
|
46
|
+
"_start_line",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(self, start_line: int, end_line: int | None = None) -> None:
|
|
50
|
+
if (
|
|
51
|
+
isinstance(start_line, bool)
|
|
52
|
+
or not isinstance(start_line, int)
|
|
53
|
+
or start_line < 0
|
|
54
|
+
or start_line > _MAX_SAFE_INTEGER
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("Invalid line range")
|
|
57
|
+
if end_line is not None and (
|
|
58
|
+
isinstance(end_line, bool)
|
|
59
|
+
or not isinstance(end_line, int)
|
|
60
|
+
or end_line <= start_line
|
|
61
|
+
or end_line > _MAX_SAFE_INTEGER
|
|
62
|
+
):
|
|
63
|
+
raise ValueError("Invalid line range")
|
|
64
|
+
self._start_line = start_line
|
|
65
|
+
self._end_line = math.inf if end_line is None else end_line
|
|
66
|
+
self._position = 0
|
|
67
|
+
self._newlines = 0
|
|
68
|
+
self._line_start = 0
|
|
69
|
+
self._start: int | None = None
|
|
70
|
+
self._end: int | None = None
|
|
71
|
+
self._first_line_end: int | None = None
|
|
72
|
+
self._last_line_start: int | None = None
|
|
73
|
+
self._selected_bytes = 0
|
|
74
|
+
self._first_line_bytes = 0
|
|
75
|
+
self._selection: codecs.IncrementalDecoder | None = None
|
|
76
|
+
self._first_line: codecs.IncrementalDecoder | None = None
|
|
77
|
+
self._head: list[int] | None = []
|
|
78
|
+
self._bom = False
|
|
79
|
+
if start_line == 0:
|
|
80
|
+
self._begin(0)
|
|
81
|
+
|
|
82
|
+
def push(self, chunk: bytes) -> None:
|
|
83
|
+
"""Feed the next bytes of the file."""
|
|
84
|
+
|
|
85
|
+
if self._head is not None:
|
|
86
|
+
take = min(3 - len(self._head), len(chunk))
|
|
87
|
+
self._head.extend(chunk[:take])
|
|
88
|
+
if len(self._head) < 3:
|
|
89
|
+
return
|
|
90
|
+
self._release_head()
|
|
91
|
+
chunk = chunk[take:]
|
|
92
|
+
self._process(chunk)
|
|
93
|
+
|
|
94
|
+
def _release_head(self) -> None:
|
|
95
|
+
head = bytes(self._head or ())
|
|
96
|
+
self._head = None
|
|
97
|
+
self._bom = starts_with_bom(head)
|
|
98
|
+
self._process(head)
|
|
99
|
+
|
|
100
|
+
def _process(self, chunk: bytes) -> None:
|
|
101
|
+
base = self._position
|
|
102
|
+
consumed = 0
|
|
103
|
+
index = chunk.find(b"\n")
|
|
104
|
+
while index != -1:
|
|
105
|
+
# The newline ends line ``self._newlines``; it belongs to the selection between lines only.
|
|
106
|
+
self._feed(chunk, base, consumed, index)
|
|
107
|
+
line = self._newlines
|
|
108
|
+
position = base + index
|
|
109
|
+
if line == self._start_line:
|
|
110
|
+
self._end_first_line(position)
|
|
111
|
+
if line == self._end_line - 1:
|
|
112
|
+
self._end_selection(position)
|
|
113
|
+
self._feed(chunk, base, index, index + 1)
|
|
114
|
+
consumed = index + 1
|
|
115
|
+
self._newlines += 1
|
|
116
|
+
self._line_start = position + 1
|
|
117
|
+
if self._newlines == self._start_line:
|
|
118
|
+
self._begin(self._line_start)
|
|
119
|
+
if self._newlines == self._end_line - 1:
|
|
120
|
+
self._last_line_start = self._line_start
|
|
121
|
+
index = chunk.find(b"\n", consumed)
|
|
122
|
+
self._feed(chunk, base, consumed, len(chunk))
|
|
123
|
+
self._position += len(chunk)
|
|
124
|
+
|
|
125
|
+
def finish(self) -> LineScan:
|
|
126
|
+
"""Finish the scan; no more bytes may follow."""
|
|
127
|
+
|
|
128
|
+
if self._head is not None:
|
|
129
|
+
self._release_head()
|
|
130
|
+
size = self._position
|
|
131
|
+
if self._start is None:
|
|
132
|
+
return LineScan(
|
|
133
|
+
newlines=self._newlines,
|
|
134
|
+
start=size,
|
|
135
|
+
end=size,
|
|
136
|
+
firstLineEnd=size,
|
|
137
|
+
lastLineStart=size,
|
|
138
|
+
selectedBytes=0,
|
|
139
|
+
firstLineBytes=0,
|
|
140
|
+
)
|
|
141
|
+
if self._first_line_end is None:
|
|
142
|
+
self._end_first_line(size)
|
|
143
|
+
if self._end is None:
|
|
144
|
+
self._end_selection(size)
|
|
145
|
+
return LineScan(
|
|
146
|
+
newlines=self._newlines,
|
|
147
|
+
start=self._start,
|
|
148
|
+
end=self._end if self._end is not None else size,
|
|
149
|
+
firstLineEnd=self._first_line_end if self._first_line_end is not None else size,
|
|
150
|
+
# A selection that reaches past the last line ends with the last line.
|
|
151
|
+
lastLineStart=self._last_line_start if self._last_line_start is not None else self._line_start,
|
|
152
|
+
selectedBytes=self._selected_bytes,
|
|
153
|
+
firstLineBytes=self._first_line_bytes,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _begin(self, start: int) -> None:
|
|
157
|
+
self._start = start
|
|
158
|
+
if self._start_line == self._end_line - 1:
|
|
159
|
+
self._last_line_start = start
|
|
160
|
+
self._selection = range_decoder()
|
|
161
|
+
self._first_line = range_decoder()
|
|
162
|
+
|
|
163
|
+
def _end_first_line(self, position: int) -> None:
|
|
164
|
+
self._first_line_end = position
|
|
165
|
+
if self._first_line is not None:
|
|
166
|
+
self._first_line_bytes += utf8_byte_length(self._first_line.decode(b"", True))
|
|
167
|
+
self._first_line = None
|
|
168
|
+
|
|
169
|
+
def _end_selection(self, position: int) -> None:
|
|
170
|
+
self._end = position
|
|
171
|
+
if self._selection is not None:
|
|
172
|
+
self._selected_bytes += utf8_byte_length(self._selection.decode(b"", True))
|
|
173
|
+
self._selection = None
|
|
174
|
+
|
|
175
|
+
def _feed(self, chunk: bytes, base: int, start: int, end: int) -> None:
|
|
176
|
+
"""Feed ``chunk[start:end]``, which starts at file offset ``base``, to open decoders."""
|
|
177
|
+
|
|
178
|
+
# Decoding the whole file drops a leading byte-order mark.
|
|
179
|
+
if self._bom and base + start < 3:
|
|
180
|
+
start = min(end, 3 - base)
|
|
181
|
+
if end <= start:
|
|
182
|
+
return
|
|
183
|
+
data = chunk[start:end]
|
|
184
|
+
if self._selection is not None:
|
|
185
|
+
self._selected_bytes += utf8_byte_length(self._selection.decode(data, False))
|
|
186
|
+
if self._first_line is not None:
|
|
187
|
+
self._first_line_bytes += utf8_byte_length(self._first_line.decode(data, False))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
__all__ = ["LineScanner"]
|