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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. 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
+ ]
@@ -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"]