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,68 @@
|
|
|
1
|
+
"""Serialization of ``write`` and ``edit`` mutations of one file within this process.
|
|
2
|
+
|
|
3
|
+
Port of the v1.3.0 ``src/tools/file-mutation-queue.ts``. Files are keyed by the environment's
|
|
4
|
+
:attr:`~pi_durable.env.FileSystem.id` and the canonical path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
|
|
13
|
+
from ..context import Context
|
|
14
|
+
from ..env import ExecutionEnv, get_or_throw
|
|
15
|
+
from .path_utils import canonical_path
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
#: Tail of the mutation chain of each file, keyed by file system id and canonical path.
|
|
20
|
+
_queues: dict[str, asyncio.Future[None]] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _mutation_key(env: ExecutionEnv, path: str, context: Context) -> str:
|
|
24
|
+
absolute_path = get_or_throw(await env.absolute_path(path, context))
|
|
25
|
+
return f"{getattr(env, 'id', '')}\0{await canonical_path(env, absolute_path, context)}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def with_file_mutation_queue(
|
|
29
|
+
env: ExecutionEnv,
|
|
30
|
+
path: str,
|
|
31
|
+
fn: Callable[[], Awaitable[T]],
|
|
32
|
+
context: Context,
|
|
33
|
+
) -> T:
|
|
34
|
+
"""Run ``fn`` after earlier mutations of the same file system and canonical path settle.
|
|
35
|
+
|
|
36
|
+
Other files, and other file systems, never wait. This is not a lock against ``bash`` or other
|
|
37
|
+
processes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
key = await _mutation_key(env, path, context)
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
previous = _queues.get(key)
|
|
43
|
+
if previous is None:
|
|
44
|
+
ready: asyncio.Future[None] = loop.create_future()
|
|
45
|
+
ready.set_result(None)
|
|
46
|
+
previous = ready
|
|
47
|
+
done: asyncio.Future[None] = loop.create_future()
|
|
48
|
+
# Take the slot without awaiting, so no other call can take it in between.
|
|
49
|
+
_queues[key] = done
|
|
50
|
+
entered = False
|
|
51
|
+
try:
|
|
52
|
+
# Shield the shared tail: cancelling this waiter must not cancel the mutation it waits on.
|
|
53
|
+
await asyncio.shield(previous)
|
|
54
|
+
entered = True
|
|
55
|
+
return await fn()
|
|
56
|
+
finally:
|
|
57
|
+
if not done.done():
|
|
58
|
+
done.set_result(None)
|
|
59
|
+
if _queues.get(key) is done:
|
|
60
|
+
# A cancelled waiter never mutates, so the next call must still wait for the mutation
|
|
61
|
+
# it was queued behind rather than proceed beside it.
|
|
62
|
+
if not entered and not previous.done():
|
|
63
|
+
_queues[key] = previous
|
|
64
|
+
else:
|
|
65
|
+
del _queues[key]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["with_file_mutation_queue"]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Image type and header detection for the ``read`` tool.
|
|
2
|
+
|
|
3
|
+
Port of the v1.3.0 ``src/tools/image.ts``. Only headers and, for PNG, the chunk headers up to the
|
|
4
|
+
first ``acTL`` or ``IDAT`` are read; image pixels are never decoded here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from ..context import Context
|
|
13
|
+
from ..env import BinaryReader, get_or_throw
|
|
14
|
+
|
|
15
|
+
PNG_SIGNATURE = (0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
|
16
|
+
#: Bytes every check except the APNG chunk walk needs: BMP reads up to offset 29.
|
|
17
|
+
HEADER_BYTES = 32
|
|
18
|
+
BLOCK_BYTES = 64 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ByteSource(Protocol):
|
|
22
|
+
"""Positional reads of a file of ``size`` bytes."""
|
|
23
|
+
|
|
24
|
+
size: int
|
|
25
|
+
|
|
26
|
+
async def read(self, offset: int, length: int) -> bytes: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def detect_supported_image_mime_type_of(source: ByteSource) -> str | None:
|
|
30
|
+
"""``detect_supported_image_mime_type`` of a whole file, reading only its header."""
|
|
31
|
+
|
|
32
|
+
header = await source.read(0, HEADER_BYTES)
|
|
33
|
+
if not _starts_with(header, PNG_SIGNATURE):
|
|
34
|
+
return detect_supported_image_mime_type(header)
|
|
35
|
+
if _is_png(header) and not await _is_animated_png_of(source):
|
|
36
|
+
return "image/png"
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _is_animated_png_of(source: ByteSource) -> bool:
|
|
41
|
+
block = b""
|
|
42
|
+
block_start = 0
|
|
43
|
+
|
|
44
|
+
async def bytes_at(offset: int, length: int) -> bytes:
|
|
45
|
+
nonlocal block, block_start
|
|
46
|
+
if offset < block_start or offset + length > block_start + len(block):
|
|
47
|
+
block_start = offset
|
|
48
|
+
block = await source.read(offset, BLOCK_BYTES)
|
|
49
|
+
return block[offset - block_start : offset - block_start + length]
|
|
50
|
+
|
|
51
|
+
offset = len(PNG_SIGNATURE)
|
|
52
|
+
while offset + 8 <= source.size:
|
|
53
|
+
chunk_header = await bytes_at(offset, 8)
|
|
54
|
+
chunk_length = _read_uint32_be(chunk_header, 0)
|
|
55
|
+
if _starts_with_ascii(chunk_header, 4, "acTL"):
|
|
56
|
+
return True
|
|
57
|
+
if _starts_with_ascii(chunk_header, 4, "IDAT"):
|
|
58
|
+
return False
|
|
59
|
+
next_offset = offset + 8 + chunk_length + 4
|
|
60
|
+
if next_offset <= offset or next_offset > source.size:
|
|
61
|
+
return False
|
|
62
|
+
offset = next_offset
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_supported_image_mime_type(buffer: bytes) -> str | None:
|
|
67
|
+
"""The MIME type of a supported image, or ``None``."""
|
|
68
|
+
|
|
69
|
+
if _starts_with(buffer, (0xFF, 0xD8, 0xFF)):
|
|
70
|
+
return None if _byte(buffer, 3) == 0xF7 else "image/jpeg"
|
|
71
|
+
if _starts_with(buffer, PNG_SIGNATURE):
|
|
72
|
+
return "image/png" if _is_png(buffer) and not _is_animated_png(buffer) else None
|
|
73
|
+
if _starts_with_ascii(buffer, 0, "GIF87a") or _starts_with_ascii(buffer, 0, "GIF89a"):
|
|
74
|
+
return "image/gif"
|
|
75
|
+
if _starts_with_ascii(buffer, 0, "RIFF") and _starts_with_ascii(buffer, 8, "WEBP"):
|
|
76
|
+
return "image/webp"
|
|
77
|
+
if _starts_with_ascii(buffer, 0, "BM") and _is_bmp(buffer):
|
|
78
|
+
return "image/bmp"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_png(buffer: bytes) -> bool:
|
|
83
|
+
return (
|
|
84
|
+
len(buffer) >= 16
|
|
85
|
+
and _read_uint32_be(buffer, len(PNG_SIGNATURE)) == 13
|
|
86
|
+
and _starts_with_ascii(buffer, 12, "IHDR")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_animated_png(buffer: bytes) -> bool:
|
|
91
|
+
offset = len(PNG_SIGNATURE)
|
|
92
|
+
while offset + 8 <= len(buffer):
|
|
93
|
+
chunk_length = _read_uint32_be(buffer, offset)
|
|
94
|
+
chunk_type_offset = offset + 4
|
|
95
|
+
if _starts_with_ascii(buffer, chunk_type_offset, "acTL"):
|
|
96
|
+
return True
|
|
97
|
+
if _starts_with_ascii(buffer, chunk_type_offset, "IDAT"):
|
|
98
|
+
return False
|
|
99
|
+
next_offset = offset + 8 + chunk_length + 4
|
|
100
|
+
if next_offset <= offset or next_offset > len(buffer):
|
|
101
|
+
return False
|
|
102
|
+
offset = next_offset
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def image_dimensions(bytes_: bytes, mime_type: str) -> dict[str, int] | None:
|
|
107
|
+
"""The width and height an image declares in its header, without decoding it."""
|
|
108
|
+
|
|
109
|
+
length = len(bytes_)
|
|
110
|
+
if mime_type == "image/png":
|
|
111
|
+
if length < 24:
|
|
112
|
+
return None
|
|
113
|
+
return {"width": _read_uint32_be(bytes_, 16), "height": _read_uint32_be(bytes_, 20)}
|
|
114
|
+
if mime_type == "image/gif":
|
|
115
|
+
if length < 10:
|
|
116
|
+
return None
|
|
117
|
+
return {"width": _read_uint16_le(bytes_, 6), "height": _read_uint16_le(bytes_, 8)}
|
|
118
|
+
if mime_type == "image/webp":
|
|
119
|
+
if length < 30:
|
|
120
|
+
return None
|
|
121
|
+
if _starts_with_ascii(bytes_, 12, "VP8 "):
|
|
122
|
+
return {
|
|
123
|
+
"width": _read_uint16_le(bytes_, 26) & 0x3FFF,
|
|
124
|
+
"height": _read_uint16_le(bytes_, 28) & 0x3FFF,
|
|
125
|
+
}
|
|
126
|
+
if _starts_with_ascii(bytes_, 12, "VP8L"):
|
|
127
|
+
bits = _read_uint32_le(bytes_, 21)
|
|
128
|
+
return {
|
|
129
|
+
"width": (bits & 0x3FFF) + 1,
|
|
130
|
+
"height": ((bits >> 14) & 0x3FFF) + 1,
|
|
131
|
+
}
|
|
132
|
+
if _starts_with_ascii(bytes_, 12, "VP8X"):
|
|
133
|
+
|
|
134
|
+
def uint24(offset: int) -> int:
|
|
135
|
+
return _read_uint16_le(bytes_, offset) + (_byte(bytes_, offset + 2) << 16)
|
|
136
|
+
|
|
137
|
+
return {"width": uint24(24) + 1, "height": uint24(27) + 1}
|
|
138
|
+
return None
|
|
139
|
+
if mime_type == "image/jpeg":
|
|
140
|
+
offset = 2
|
|
141
|
+
while offset + 9 <= length:
|
|
142
|
+
if _byte(bytes_, offset) != 0xFF:
|
|
143
|
+
return None
|
|
144
|
+
marker = _byte(bytes_, offset + 1)
|
|
145
|
+
if marker == 0xFF:
|
|
146
|
+
offset += 1
|
|
147
|
+
continue
|
|
148
|
+
# SOF0 to SOF15, except DHT (C4), JPG (C8), and DAC (CC), which share the range.
|
|
149
|
+
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
|
150
|
+
return {
|
|
151
|
+
"width": _read_uint16_be(bytes_, offset + 7),
|
|
152
|
+
"height": _read_uint16_be(bytes_, offset + 5),
|
|
153
|
+
}
|
|
154
|
+
offset += 2 + _read_uint16_be(bytes_, offset + 2)
|
|
155
|
+
return None
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_bmp(buffer: bytes) -> bool:
|
|
160
|
+
if len(buffer) < 26:
|
|
161
|
+
return False
|
|
162
|
+
declared_file_size = _read_uint32_le(buffer, 2)
|
|
163
|
+
pixel_data_offset = _read_uint32_le(buffer, 10)
|
|
164
|
+
dib_header_size = _read_uint32_le(buffer, 14)
|
|
165
|
+
if declared_file_size != 0 and declared_file_size < 26:
|
|
166
|
+
return False
|
|
167
|
+
if pixel_data_offset < 14 + dib_header_size:
|
|
168
|
+
return False
|
|
169
|
+
if declared_file_size != 0 and pixel_data_offset >= declared_file_size:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
if dib_header_size == 12:
|
|
173
|
+
color_planes = _read_uint16_le(buffer, 22)
|
|
174
|
+
bits_per_pixel = _read_uint16_le(buffer, 24)
|
|
175
|
+
elif 40 <= dib_header_size <= 124:
|
|
176
|
+
if len(buffer) < 30:
|
|
177
|
+
return False
|
|
178
|
+
color_planes = _read_uint16_le(buffer, 26)
|
|
179
|
+
bits_per_pixel = _read_uint16_le(buffer, 28)
|
|
180
|
+
else:
|
|
181
|
+
return False
|
|
182
|
+
return color_planes == 1 and bits_per_pixel in (1, 4, 8, 16, 24, 32)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _byte(buffer: bytes, offset: int) -> int:
|
|
186
|
+
return buffer[offset] if offset < len(buffer) else 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _read_uint16_le(buffer: bytes, offset: int) -> int:
|
|
190
|
+
return _byte(buffer, offset) + (_byte(buffer, offset + 1) << 8)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_uint16_be(buffer: bytes, offset: int) -> int:
|
|
194
|
+
return (_byte(buffer, offset) << 8) + _byte(buffer, offset + 1)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _read_uint32_be(buffer: bytes, offset: int) -> int:
|
|
198
|
+
return (
|
|
199
|
+
_byte(buffer, offset) * 0x1000000
|
|
200
|
+
+ (_byte(buffer, offset + 1) << 16)
|
|
201
|
+
+ (_byte(buffer, offset + 2) << 8)
|
|
202
|
+
+ _byte(buffer, offset + 3)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _read_uint32_le(buffer: bytes, offset: int) -> int:
|
|
207
|
+
return (
|
|
208
|
+
_byte(buffer, offset)
|
|
209
|
+
+ (_byte(buffer, offset + 1) << 8)
|
|
210
|
+
+ (_byte(buffer, offset + 2) << 16)
|
|
211
|
+
+ _byte(buffer, offset + 3) * 0x1000000
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _starts_with(buffer: bytes, values: tuple[int, ...]) -> bool:
|
|
216
|
+
if len(buffer) < len(values):
|
|
217
|
+
return False
|
|
218
|
+
return all(buffer[index] == value for index, value in enumerate(values))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _starts_with_ascii(buffer: bytes, offset: int, text: str) -> bool:
|
|
222
|
+
if len(buffer) < offset + len(text):
|
|
223
|
+
return False
|
|
224
|
+
return all(buffer[offset + index] == ord(character) for index, character in enumerate(text))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(slots=True)
|
|
228
|
+
class ReaderByteSource:
|
|
229
|
+
"""A :class:`ByteSource` over an environment's ``BinaryReader``."""
|
|
230
|
+
|
|
231
|
+
reader: BinaryReader
|
|
232
|
+
size: int
|
|
233
|
+
context: Context
|
|
234
|
+
|
|
235
|
+
async def read(self, offset: int, length: int) -> bytes:
|
|
236
|
+
return get_or_throw(await self.reader.read(offset, length, self.context))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"BLOCK_BYTES",
|
|
241
|
+
"HEADER_BYTES",
|
|
242
|
+
"PNG_SIGNATURE",
|
|
243
|
+
"ByteSource",
|
|
244
|
+
"ReaderByteSource",
|
|
245
|
+
"detect_supported_image_mime_type",
|
|
246
|
+
"detect_supported_image_mime_type_of",
|
|
247
|
+
"image_dimensions",
|
|
248
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Image preparation protocol for the ``read`` tool.
|
|
2
|
+
|
|
3
|
+
Port of the v1.3.0 ``src/tools/image-processor.ts``. There is no Python image implementation; an
|
|
4
|
+
application supplies an :class:`ImageProcessor` when it wants ``read`` to resize or convert images.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
from typing import Any, Protocol, TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImageLimits(TypedDict):
|
|
14
|
+
"""Limits an image the model sees must keep."""
|
|
15
|
+
|
|
16
|
+
max_width: int
|
|
17
|
+
"""Pixels. Default 2000."""
|
|
18
|
+
|
|
19
|
+
max_height: int
|
|
20
|
+
"""Pixels. Default 2000."""
|
|
21
|
+
|
|
22
|
+
max_bytes: int
|
|
23
|
+
"""Bytes of base64. Default 4.5 MiB, below Anthropic's 5 MB."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_IMAGE_LIMITS: ImageLimits = {
|
|
27
|
+
"max_width": 2000,
|
|
28
|
+
"max_height": 2000,
|
|
29
|
+
"max_bytes": int(4.5 * 1024 * 1024),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PreparedImage(TypedDict, total=False):
|
|
34
|
+
"""An image the model can take: a supported format, within the limits, as base64."""
|
|
35
|
+
|
|
36
|
+
data: str
|
|
37
|
+
mime_type: str
|
|
38
|
+
"""The image's format, such as ``image/png``."""
|
|
39
|
+
|
|
40
|
+
resized: dict[str, Any]
|
|
41
|
+
"""Set when the image was resized: its size before and after, for the model to map coordinates back."""
|
|
42
|
+
|
|
43
|
+
converted_from: str
|
|
44
|
+
"""The original format, set when it differs from ``mime_type``."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ImageProcessor(Protocol):
|
|
48
|
+
"""Decodes, orients, resizes, and re-encodes images so the model can take them."""
|
|
49
|
+
|
|
50
|
+
async def prepare(self, data: bytes, mime_type: str, limits: ImageLimits) -> PreparedImage | None: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: Formats every provider takes inline; others, such as BMP, need converting.
|
|
54
|
+
INLINE_IMAGE_TYPES: frozenset[str] = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_base64(data: bytes) -> str:
|
|
58
|
+
"""Standard base64 of ``data``."""
|
|
59
|
+
|
|
60
|
+
return base64.b64encode(data).decode("ascii")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def base64_length(byte_length: int) -> int:
|
|
64
|
+
"""The length of the base64 of ``byte_length`` bytes."""
|
|
65
|
+
|
|
66
|
+
return ((byte_length + 2) // 3) * 4
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"DEFAULT_IMAGE_LIMITS",
|
|
71
|
+
"INLINE_IMAGE_TYPES",
|
|
72
|
+
"ImageLimits",
|
|
73
|
+
"ImageProcessor",
|
|
74
|
+
"PreparedImage",
|
|
75
|
+
"base64_length",
|
|
76
|
+
"to_base64",
|
|
77
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tool path normalization and confinement to the execution environment's working directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from ..context import Context
|
|
10
|
+
from ..env import ExecutionEnv, FileError, get_or_throw
|
|
11
|
+
|
|
12
|
+
UNICODE_SPACES = re.compile("[\u00a0\u2000-\u200a\u202f\u205f\u3000]")
|
|
13
|
+
NARROW_NO_BREAK_SPACE = "\u202f"
|
|
14
|
+
_AM_PM = re.compile(" (AM|PM)\\.", re.IGNORECASE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_tool_path(path: str) -> str:
|
|
18
|
+
"""Normalize Unicode spaces and drop a leading ``@`` a model may add."""
|
|
19
|
+
|
|
20
|
+
normalized = UNICODE_SPACES.sub(" ", path)
|
|
21
|
+
return normalized[1:] if normalized.startswith("@") else normalized
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _within(root: str, path: str) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
return os.path.commonpath([root, path]) == root
|
|
27
|
+
except ValueError:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def canonical_path(env: ExecutionEnv, path: str, context: Context) -> str:
|
|
32
|
+
"""The canonical path, or the canonical parent joined with the name when the file is missing."""
|
|
33
|
+
|
|
34
|
+
result = await env.canonical_path(path, context)
|
|
35
|
+
if result.ok:
|
|
36
|
+
return result.value
|
|
37
|
+
if result.error.code == "not_supported":
|
|
38
|
+
return path
|
|
39
|
+
if result.error.code != "not_found":
|
|
40
|
+
raise result.error
|
|
41
|
+
parent = get_or_throw(await env.join_path([path, ".."], context))
|
|
42
|
+
if parent == path or not path.startswith(parent):
|
|
43
|
+
return path
|
|
44
|
+
separator = 0 if parent.endswith(("/", "\\")) else 1
|
|
45
|
+
name = path[len(parent) + separator :]
|
|
46
|
+
return get_or_throw(await env.join_path([await canonical_path(env, parent, context), name], context))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def ensure_within_cwd(env: ExecutionEnv, resolved: str, original: str, context: Context) -> str:
|
|
50
|
+
"""Reject a resolved path that leaves the environment's working directory."""
|
|
51
|
+
|
|
52
|
+
root = get_or_throw(await env.absolute_path(".", context))
|
|
53
|
+
if not _within(os.path.abspath(root), os.path.abspath(resolved)):
|
|
54
|
+
raise ValueError(f"Path {original} is outside the working directory")
|
|
55
|
+
try:
|
|
56
|
+
canonical_root = await canonical_path(env, root, context)
|
|
57
|
+
canonical_target = await canonical_path(env, resolved, context)
|
|
58
|
+
except FileError as error:
|
|
59
|
+
if error.code in ("not_supported", "not_found"):
|
|
60
|
+
# A filesystem without canonical paths falls back to the lexical check above.
|
|
61
|
+
return resolved
|
|
62
|
+
raise
|
|
63
|
+
if not _within(os.path.abspath(canonical_root), os.path.abspath(canonical_target)):
|
|
64
|
+
raise ValueError(f"Path {original} is outside the working directory")
|
|
65
|
+
return resolved
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def resolve_tool_path(env: ExecutionEnv, path: str, context: Context) -> str:
|
|
69
|
+
"""Resolve the path against the environment, refusing to escape its working directory."""
|
|
70
|
+
|
|
71
|
+
resolved = get_or_throw(await env.absolute_path(normalize_tool_path(path), context))
|
|
72
|
+
return await ensure_within_cwd(env, resolved, path, context)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def resolve_read_tool_path(env: ExecutionEnv, path: str, context: Context) -> str:
|
|
76
|
+
"""Resolve a ``read`` path, trying the Unicode variants a filesystem may use."""
|
|
77
|
+
|
|
78
|
+
resolved = await resolve_tool_path(env, path, context)
|
|
79
|
+
variants: Sequence[str] = (
|
|
80
|
+
resolved,
|
|
81
|
+
_AM_PM.sub(lambda match: f"{NARROW_NO_BREAK_SPACE}{match.group(1)}.", resolved),
|
|
82
|
+
# NFD, for filesystems that store decomposed names.
|
|
83
|
+
_normalize_nfd(resolved),
|
|
84
|
+
resolved.replace("'", "\u2019"),
|
|
85
|
+
_normalize_nfd(resolved.replace("'", "\u2019")),
|
|
86
|
+
)
|
|
87
|
+
seen: set[str] = set()
|
|
88
|
+
for variant in variants:
|
|
89
|
+
if variant in seen:
|
|
90
|
+
continue
|
|
91
|
+
seen.add(variant)
|
|
92
|
+
if get_or_throw(await env.exists(variant, context)):
|
|
93
|
+
return variant
|
|
94
|
+
return resolved
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_nfd(text: str) -> str:
|
|
98
|
+
import unicodedata
|
|
99
|
+
|
|
100
|
+
return unicodedata.normalize("NFD", text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"NARROW_NO_BREAK_SPACE",
|
|
105
|
+
"UNICODE_SPACES",
|
|
106
|
+
"canonical_path",
|
|
107
|
+
"ensure_within_cwd",
|
|
108
|
+
"normalize_tool_path",
|
|
109
|
+
"resolve_read_tool_path",
|
|
110
|
+
"resolve_tool_path",
|
|
111
|
+
]
|