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,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
+ ]