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,467 @@
|
|
|
1
|
+
"""Bounded output streams and adaptive progress commits.
|
|
2
|
+
|
|
3
|
+
Port of Chord 1.3.0 ``output.ts``, the module the upstream tool task uses to bound a call's
|
|
4
|
+
running output and to pace its progress commits. ``OutputBuffer`` keeps a head or tail window of a
|
|
5
|
+
stream at a cost proportional to each chunk, and ``Progress`` paces commits of a changing value by
|
|
6
|
+
their written size.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import codecs
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..delta import overlap_strings
|
|
20
|
+
|
|
21
|
+
#: ``truncate.ts`` defaults shared by tools: whichever limit is hit first wins.
|
|
22
|
+
DEFAULT_MAX_LINES = 2000
|
|
23
|
+
DEFAULT_MAX_BYTES = 50 * 1024
|
|
24
|
+
|
|
25
|
+
#: Each progress commit also buys a pause proportional to what it wrote.
|
|
26
|
+
PROGRESS_BYTES_PER_SECOND = 100 * 1024
|
|
27
|
+
|
|
28
|
+
_INVALID_OUTPUT = re.compile("[\x00-\x08\x0b-\x1f\ufff9-\ufffb]")
|
|
29
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class OutputLimits:
|
|
34
|
+
"""Retention limits of one output stream."""
|
|
35
|
+
|
|
36
|
+
max_bytes: int = DEFAULT_MAX_BYTES
|
|
37
|
+
max_lines: int = DEFAULT_MAX_LINES
|
|
38
|
+
retain: str = "head"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class BoundedOutput:
|
|
43
|
+
"""Retained output and what the limits dropped."""
|
|
44
|
+
|
|
45
|
+
text: str
|
|
46
|
+
dropped_bytes: int
|
|
47
|
+
dropped_lines: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class OutputSlice:
|
|
52
|
+
"""An exact slice of the input within the limits, and what it left out."""
|
|
53
|
+
|
|
54
|
+
text: str
|
|
55
|
+
bytes: int
|
|
56
|
+
dropped_bytes: int
|
|
57
|
+
dropped_lines: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sanitize_output(text: str) -> str:
|
|
61
|
+
"""Remove control characters that break display and transcripts; tabs and newlines stay."""
|
|
62
|
+
|
|
63
|
+
return _INVALID_OUTPUT.sub("", text)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def utf8_byte_length(text: str) -> int:
|
|
67
|
+
"""UTF-8 byte length of ``text``; a lone surrogate counts as U+FFFD, three bytes."""
|
|
68
|
+
|
|
69
|
+
if text.isascii():
|
|
70
|
+
return len(text)
|
|
71
|
+
return len(_encode(text))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _encode(text: str) -> bytes:
|
|
75
|
+
if _SURROGATE.search(text) is None:
|
|
76
|
+
return text.encode("utf-8")
|
|
77
|
+
return _SURROGATE.sub("\ufffd", text).encode("utf-8")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _decode(data: bytes) -> str:
|
|
81
|
+
return data.decode("utf-8", "replace")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def bound_output(text: str, limits: OutputLimits) -> OutputSlice:
|
|
85
|
+
"""Bound ``text`` to whole lines within the limits: the first lines for ``head``, the last for ``tail``.
|
|
86
|
+
|
|
87
|
+
The result is an exact slice, trailing newline included. A single line longer than ``max_bytes``
|
|
88
|
+
is cut at the byte limit on a character boundary.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
data = _encode(text)
|
|
92
|
+
start, end = _head_range(data, limits) if limits.retain == "head" else _tail_range(data, limits)
|
|
93
|
+
kept = data[start:end]
|
|
94
|
+
return OutputSlice(
|
|
95
|
+
text=text if len(kept) == len(data) else _decode(kept),
|
|
96
|
+
bytes=len(kept),
|
|
97
|
+
dropped_bytes=len(data) - len(kept),
|
|
98
|
+
dropped_lines=_line_count(data) - _line_count(kept),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _head_range(data: bytes, limits: OutputLimits) -> tuple[int, int]:
|
|
103
|
+
if limits.max_lines == 0 or limits.max_bytes == 0:
|
|
104
|
+
return 0, 0
|
|
105
|
+
end = len(data)
|
|
106
|
+
lines = 0
|
|
107
|
+
index = data.find(b"\n")
|
|
108
|
+
while index != -1:
|
|
109
|
+
lines += 1
|
|
110
|
+
if lines == limits.max_lines:
|
|
111
|
+
end = index + 1
|
|
112
|
+
break
|
|
113
|
+
index = data.find(b"\n", index + 1)
|
|
114
|
+
if end > limits.max_bytes:
|
|
115
|
+
newline = data.rfind(b"\n", 0, limits.max_bytes)
|
|
116
|
+
end = _character_end(data, limits.max_bytes) if newline == -1 else newline + 1
|
|
117
|
+
return 0, end
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _tail_range(data: bytes, limits: OutputLimits) -> tuple[int, int]:
|
|
121
|
+
if limits.max_lines == 0 or limits.max_bytes == 0:
|
|
122
|
+
return len(data), len(data)
|
|
123
|
+
# A trailing newline ends the last line rather than starting another.
|
|
124
|
+
last = len(data) - 2 if data.endswith(b"\n") else len(data) - 1
|
|
125
|
+
start = 0
|
|
126
|
+
lines = 1
|
|
127
|
+
index = -1 if last < 0 else data.rfind(b"\n", 0, last + 1)
|
|
128
|
+
while index != -1:
|
|
129
|
+
if lines == limits.max_lines:
|
|
130
|
+
start = index + 1
|
|
131
|
+
break
|
|
132
|
+
lines += 1
|
|
133
|
+
index = -1 if index == 0 else data.rfind(b"\n", 0, index)
|
|
134
|
+
if len(data) - start > limits.max_bytes:
|
|
135
|
+
first = len(data) - limits.max_bytes
|
|
136
|
+
newline = data.find(b"\n", max(0, first - 1))
|
|
137
|
+
# The first line starting inside the byte window, or a cut of the last line when it alone
|
|
138
|
+
# is too long.
|
|
139
|
+
start = newline + 1 if newline != -1 and newline + 1 < len(data) else _character_start(data, first)
|
|
140
|
+
return start, len(data)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _character_end(data: bytes, index: int) -> int:
|
|
144
|
+
"""The last character boundary at or before ``index``."""
|
|
145
|
+
|
|
146
|
+
end = index
|
|
147
|
+
while end > 0 and (data[end] & 0xC0) == 0x80:
|
|
148
|
+
end -= 1
|
|
149
|
+
return end
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _character_start(data: bytes, index: int) -> int:
|
|
153
|
+
"""The first character boundary at or after ``index``."""
|
|
154
|
+
|
|
155
|
+
start = index
|
|
156
|
+
while start < len(data) and (data[start] & 0xC0) == 0x80:
|
|
157
|
+
start += 1
|
|
158
|
+
return start
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _line_count(data: bytes) -> int:
|
|
162
|
+
if not data:
|
|
163
|
+
return 0
|
|
164
|
+
newlines = data.count(b"\n")
|
|
165
|
+
return newlines + (0 if data.endswith(b"\n") else 1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class OutputBuffer:
|
|
169
|
+
"""Bounded running output of one stream, such as a tool call's.
|
|
170
|
+
|
|
171
|
+
Accepting a chunk costs time proportional to the chunk: head retention stops storing once the
|
|
172
|
+
window is full, and tail retention drops stored text the window no longer needs when it
|
|
173
|
+
snapshots. Counts of the whole stream are kept so the dropped totals stay exact.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, limits: OutputLimits) -> None:
|
|
177
|
+
self._limits = limits
|
|
178
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
179
|
+
self._started = False
|
|
180
|
+
# Stored chunks: for head the start of the stream, for tail a suffix that still contains the
|
|
181
|
+
# next window.
|
|
182
|
+
self._chunks: list[tuple[str, int, int]] = []
|
|
183
|
+
self._stored_bytes = 0
|
|
184
|
+
self._stored_newlines = 0
|
|
185
|
+
self._full = False
|
|
186
|
+
self._total_bytes = 0
|
|
187
|
+
self._total_newlines = 0
|
|
188
|
+
self._ends_with_newline = True
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def stored_bytes(self) -> int:
|
|
192
|
+
"""Bytes currently held; bounded by the limits plus one chunk."""
|
|
193
|
+
|
|
194
|
+
return self._stored_bytes
|
|
195
|
+
|
|
196
|
+
def push(self, chunk: str | bytes, skipped: Mapping[str, Any] | None = None) -> bool:
|
|
197
|
+
"""Accept a chunk; return whether anything was accepted.
|
|
198
|
+
|
|
199
|
+
``skipped`` is output omitted right before the chunk, which must be more than the tail
|
|
200
|
+
window by at least one byte or line; only tail retention accepts it.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
# Bytes of an incomplete character from an earlier byte chunk come first.
|
|
204
|
+
pending = self._decoder.decode(b"") if isinstance(chunk, str) or skipped is not None else ""
|
|
205
|
+
text = chunk if isinstance(chunk, str) else self._decoder.decode(chunk, False)
|
|
206
|
+
first = not self._started and pending == "" and skipped is None
|
|
207
|
+
if pending != "" or text != "" or skipped is not None:
|
|
208
|
+
self._started = True
|
|
209
|
+
if first and isinstance(chunk, bytes) and text.startswith("\ufeff"):
|
|
210
|
+
text = text[1:]
|
|
211
|
+
if skipped is None:
|
|
212
|
+
return self._accept(pending + text)
|
|
213
|
+
if self._limits.retain != "tail":
|
|
214
|
+
raise ValueError("Skipped output requires tail retention")
|
|
215
|
+
self._accept(pending)
|
|
216
|
+
self._skip(_skip_of(skipped))
|
|
217
|
+
self._accept(text)
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
def _skip(self, skipped: OutputSkip) -> None:
|
|
221
|
+
if skipped.byte_count == 0:
|
|
222
|
+
return
|
|
223
|
+
self._total_bytes += skipped.byte_count
|
|
224
|
+
self._total_newlines += skipped.newlines
|
|
225
|
+
self._ends_with_newline = skipped.ends_with_newline
|
|
226
|
+
self._chunks = []
|
|
227
|
+
self._stored_bytes = 0
|
|
228
|
+
self._stored_newlines = 0
|
|
229
|
+
|
|
230
|
+
def end(self) -> None:
|
|
231
|
+
"""Flush an incomplete trailing character as a replacement character; call when the stream ends."""
|
|
232
|
+
|
|
233
|
+
self._accept(self._decoder.decode(b"", True))
|
|
234
|
+
|
|
235
|
+
def _accept(self, text: str) -> bool:
|
|
236
|
+
if text == "":
|
|
237
|
+
return False
|
|
238
|
+
bytes_ = utf8_byte_length(text)
|
|
239
|
+
newlines = text.count("\n")
|
|
240
|
+
self._total_bytes += bytes_
|
|
241
|
+
self._total_newlines += newlines
|
|
242
|
+
self._ends_with_newline = text.endswith("\n")
|
|
243
|
+
if self._full:
|
|
244
|
+
return True
|
|
245
|
+
self._chunks.append((text, bytes_, newlines))
|
|
246
|
+
self._stored_bytes += bytes_
|
|
247
|
+
self._stored_newlines += newlines
|
|
248
|
+
if self._limits.retain == "head":
|
|
249
|
+
# Nothing past a full window is ever needed.
|
|
250
|
+
self._full = (
|
|
251
|
+
self._stored_bytes > self._limits.max_bytes
|
|
252
|
+
or self._stored_newlines >= self._limits.max_lines
|
|
253
|
+
)
|
|
254
|
+
return True
|
|
255
|
+
# Drop leading chunks while the rest still holds more than a window: more than max_bytes
|
|
256
|
+
# bytes or max_lines newlines, plus one, so the window's line start can still be found.
|
|
257
|
+
while len(self._chunks) > 1:
|
|
258
|
+
first_bytes, first_newlines = self._chunks[0][1], self._chunks[0][2]
|
|
259
|
+
bytes_after = self._stored_bytes - first_bytes
|
|
260
|
+
newlines_after = self._stored_newlines - first_newlines
|
|
261
|
+
if bytes_after <= self._limits.max_bytes + 1 and newlines_after <= self._limits.max_lines + 1:
|
|
262
|
+
break
|
|
263
|
+
self._chunks.pop(0)
|
|
264
|
+
self._stored_bytes = bytes_after
|
|
265
|
+
self._stored_newlines = newlines_after
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
def snapshot(self) -> BoundedOutput:
|
|
269
|
+
"""Retained, sanitized output and what the limits dropped from the whole stream."""
|
|
270
|
+
|
|
271
|
+
stored = self._chunks[0][0] if len(self._chunks) == 1 else "".join(chunk[0] for chunk in self._chunks)
|
|
272
|
+
kept = bound_output(stored, self._limits)
|
|
273
|
+
stored_lines = _lines(self._stored_newlines, stored == "" or stored.endswith("\n"))
|
|
274
|
+
kept_lines = stored_lines - kept.dropped_lines
|
|
275
|
+
# Tail windows never reach back before this one, but finding a later window's first line needs
|
|
276
|
+
# what precedes it: keep the shortest suffix longer than the window by a byte or a line.
|
|
277
|
+
if self._limits.retain == "tail" or len(self._chunks) > 1:
|
|
278
|
+
text = _tail_margin(stored, self._limits) if self._limits.retain == "tail" else stored
|
|
279
|
+
bytes_ = utf8_byte_length(text) if self._limits.retain == "tail" else self._stored_bytes
|
|
280
|
+
self._chunks = [] if text == "" else [(text, bytes_, text.count("\n"))]
|
|
281
|
+
self._stored_bytes = bytes_
|
|
282
|
+
self._stored_newlines = self._chunks[0][2] if self._chunks else 0
|
|
283
|
+
return BoundedOutput(
|
|
284
|
+
text=sanitize_output(kept.text),
|
|
285
|
+
dropped_bytes=self._total_bytes - kept.bytes,
|
|
286
|
+
dropped_lines=_lines(self._total_newlines, self._ends_with_newline) - kept_lines,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True, slots=True)
|
|
291
|
+
class OutputSkip:
|
|
292
|
+
"""Output a producer omitted before a chunk, measured on the decoded text."""
|
|
293
|
+
|
|
294
|
+
byte_count: int
|
|
295
|
+
newlines: int
|
|
296
|
+
ends_with_newline: bool
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _skip_of(skipped: Mapping[str, Any]) -> OutputSkip:
|
|
300
|
+
bytes_ = skipped.get("bytes", skipped.get("byteCount", 0))
|
|
301
|
+
newlines = skipped.get("newlines", 0)
|
|
302
|
+
ends = skipped.get("ends_with_newline", skipped.get("endsWithNewline", False))
|
|
303
|
+
return OutputSkip(int(bytes_ or 0), int(newlines or 0), bool(ends))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _tail_margin(text: str, limits: OutputLimits) -> str:
|
|
307
|
+
"""The shortest suffix of ``text`` with more than ``max_bytes`` bytes or ``max_lines`` newlines."""
|
|
308
|
+
|
|
309
|
+
data = _encode(text)
|
|
310
|
+
byte_start = (
|
|
311
|
+
_character_end(data, len(data) - limits.max_bytes - 1) if len(data) > limits.max_bytes else 0
|
|
312
|
+
)
|
|
313
|
+
line_start = 0
|
|
314
|
+
newlines = 0
|
|
315
|
+
index = data.rfind(b"\n")
|
|
316
|
+
while index != -1:
|
|
317
|
+
newlines += 1
|
|
318
|
+
if newlines > limits.max_lines:
|
|
319
|
+
line_start = index
|
|
320
|
+
break
|
|
321
|
+
if index == 0:
|
|
322
|
+
break
|
|
323
|
+
index = data.rfind(b"\n", 0, index)
|
|
324
|
+
start = max(byte_start, line_start)
|
|
325
|
+
return text if start == 0 else _decode(data[start:])
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _lines(newlines: int, terminated: bool) -> int:
|
|
329
|
+
return newlines + (0 if terminated else 1)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def overlap(left: str, right: str, limit: int) -> int:
|
|
333
|
+
"""Length of the longest suffix of ``left`` that is a prefix of ``right``, bounded by ``limit``."""
|
|
334
|
+
|
|
335
|
+
return overlap_strings(left, right, limit)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _now_ms() -> float:
|
|
339
|
+
return time.monotonic() * 1000.0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class Progress:
|
|
343
|
+
"""Adaptive progress commits: the first change after an idle period commits at once; each commit
|
|
344
|
+
then delays the next by at least ``min_interval_ms`` and by its written size at 100 KiB/s.
|
|
345
|
+
|
|
346
|
+
At most one commit is in flight; changes made meanwhile coalesce into the next one.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(
|
|
350
|
+
self,
|
|
351
|
+
write: Callable[[], Any],
|
|
352
|
+
on_error: Callable[[BaseException], None],
|
|
353
|
+
min_interval_ms: int,
|
|
354
|
+
) -> None:
|
|
355
|
+
self._write = write
|
|
356
|
+
self._on_error = on_error
|
|
357
|
+
self._min_interval_ms = max(0, min_interval_ms)
|
|
358
|
+
self._waiters: list[asyncio.Future[None]] = []
|
|
359
|
+
self._timer: asyncio.TimerHandle | None = None
|
|
360
|
+
self._in_flight: asyncio.Future[None] | None = None
|
|
361
|
+
self._next_at = 0.0
|
|
362
|
+
self._dirty = False
|
|
363
|
+
self._stopped = False
|
|
364
|
+
|
|
365
|
+
def mark(self) -> None:
|
|
366
|
+
"""Schedule a commit."""
|
|
367
|
+
|
|
368
|
+
self._dirty = True
|
|
369
|
+
self._schedule()
|
|
370
|
+
|
|
371
|
+
def mark_and_wait(self) -> asyncio.Future[None]:
|
|
372
|
+
"""Schedule a commit; the future settles with the commit that includes this change."""
|
|
373
|
+
|
|
374
|
+
waiter: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
375
|
+
self._waiters.append(waiter)
|
|
376
|
+
self.mark()
|
|
377
|
+
return waiter
|
|
378
|
+
|
|
379
|
+
async def stop(self) -> list[asyncio.Future[None]]:
|
|
380
|
+
"""Stop committing and wait for the commit in flight; returns the waiters the final commit settles."""
|
|
381
|
+
|
|
382
|
+
self._stopped = True
|
|
383
|
+
if self._timer is not None:
|
|
384
|
+
self._timer.cancel()
|
|
385
|
+
self._timer = None
|
|
386
|
+
if self._in_flight is not None:
|
|
387
|
+
await self._in_flight
|
|
388
|
+
pending = self._waiters
|
|
389
|
+
self._waiters = []
|
|
390
|
+
return pending
|
|
391
|
+
|
|
392
|
+
def _schedule(self) -> None:
|
|
393
|
+
if self._stopped or self._timer is not None or self._in_flight is not None:
|
|
394
|
+
return
|
|
395
|
+
wait = self._next_at - _now_ms()
|
|
396
|
+
if wait <= 0:
|
|
397
|
+
self._flush()
|
|
398
|
+
else:
|
|
399
|
+
loop = asyncio.get_running_loop()
|
|
400
|
+
self._timer = loop.call_later(wait / 1000.0, self._fire)
|
|
401
|
+
|
|
402
|
+
def _fire(self) -> None:
|
|
403
|
+
self._timer = None
|
|
404
|
+
self._flush()
|
|
405
|
+
|
|
406
|
+
def _flush(self) -> None:
|
|
407
|
+
if self._stopped or not self._dirty:
|
|
408
|
+
return
|
|
409
|
+
self._dirty = False
|
|
410
|
+
waiters = self._waiters
|
|
411
|
+
self._waiters = []
|
|
412
|
+
started = _now_ms()
|
|
413
|
+
# Called synchronously, so a writer captures what changed before the commit is awaited.
|
|
414
|
+
try:
|
|
415
|
+
produced = self._write()
|
|
416
|
+
except BaseException as error:
|
|
417
|
+
self._fail(error, started, waiters)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
async def complete() -> None:
|
|
421
|
+
try:
|
|
422
|
+
written = await produced if hasattr(produced, "__await__") else produced
|
|
423
|
+
except BaseException as error:
|
|
424
|
+
self._fail(error, started, waiters)
|
|
425
|
+
return
|
|
426
|
+
bytes_ = int(written or 0)
|
|
427
|
+
self._next_at = started + max(
|
|
428
|
+
self._min_interval_ms, bytes_ * 1000.0 / PROGRESS_BYTES_PER_SECOND
|
|
429
|
+
)
|
|
430
|
+
for waiter in waiters:
|
|
431
|
+
if not waiter.done():
|
|
432
|
+
waiter.set_result(None)
|
|
433
|
+
|
|
434
|
+
self._in_flight = asyncio.ensure_future(complete())
|
|
435
|
+
self._in_flight.add_done_callback(lambda _: None)
|
|
436
|
+
|
|
437
|
+
def finished(_: Any) -> None:
|
|
438
|
+
self._in_flight = None
|
|
439
|
+
if self._dirty:
|
|
440
|
+
self._schedule()
|
|
441
|
+
|
|
442
|
+
self._in_flight.add_done_callback(finished)
|
|
443
|
+
|
|
444
|
+
def _fail(self, error: BaseException, started: float, waiters: list[asyncio.Future[None]]) -> None:
|
|
445
|
+
self._next_at = started + self._min_interval_ms
|
|
446
|
+
for waiter in waiters:
|
|
447
|
+
if not waiter.done():
|
|
448
|
+
waiter.set_exception(error)
|
|
449
|
+
waiter.exception()
|
|
450
|
+
self._on_error(error)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = [
|
|
454
|
+
"DEFAULT_MAX_BYTES",
|
|
455
|
+
"DEFAULT_MAX_LINES",
|
|
456
|
+
"PROGRESS_BYTES_PER_SECOND",
|
|
457
|
+
"BoundedOutput",
|
|
458
|
+
"OutputBuffer",
|
|
459
|
+
"OutputLimits",
|
|
460
|
+
"OutputSkip",
|
|
461
|
+
"OutputSlice",
|
|
462
|
+
"Progress",
|
|
463
|
+
"bound_output",
|
|
464
|
+
"overlap",
|
|
465
|
+
"sanitize_output",
|
|
466
|
+
"utf8_byte_length",
|
|
467
|
+
]
|