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,1547 @@
1
+ """POSIX execution environment.
2
+
3
+ A local filesystem and shell implementation of the portable :class:`~pi_durable.env.ExecutionEnv`
4
+ protocol. Filesystem operations return failures instead of throwing; shell execution streams
5
+ decoded output, bounds it with a timeout, and can spill the complete raw output to a temporary
6
+ file. Watches poll snapshots, which sees creations, replacements, and renamed ancestors without
7
+ depending on a platform event stream.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import errno
14
+ import hashlib
15
+ import math
16
+ import os
17
+ import re
18
+ import shutil
19
+ import signal
20
+ import stat
21
+ import tempfile
22
+ import time
23
+ import uuid
24
+ from collections.abc import Callable, Sequence
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Literal, cast
28
+ from urllib.parse import urlparse
29
+ from urllib.request import url2pathname
30
+
31
+ from ..context import AbortError, Context
32
+ from . import (
33
+ BinaryReader,
34
+ DirPage,
35
+ DirReader,
36
+ Err,
37
+ ExecutionEnv,
38
+ ExecutionError,
39
+ FileError,
40
+ FileErrorCode,
41
+ FileInfo,
42
+ FileListing,
43
+ FileWatcher,
44
+ LineScan,
45
+ ListFilesOptions,
46
+ Ok,
47
+ Result,
48
+ ShellExecOptions,
49
+ ShellExecResult,
50
+ ShellOutputInfo,
51
+ TextLine,
52
+ TextLineReader,
53
+ WatchChange,
54
+ WatchError,
55
+ WatchPaths,
56
+ WatchTarget,
57
+ err,
58
+ ok,
59
+ to_error,
60
+ )
61
+ from .decode import StreamDecoder
62
+ from .line_scan import LineScanner
63
+
64
+ _MAX_SAFE_INTEGER = 9_007_199_254_740_991
65
+ _MAX_TIMEOUT_MS = 2_147_483_647
66
+ _EXIT_STDIO_GRACE_SECONDS = 0.1
67
+ _BINARY_READ_CHUNK = 1024 * 1024
68
+ _LINE_READ_CHUNK = 64 * 1024
69
+ _DEFAULT_POLL_INTERVAL_MS = 2000
70
+ _DEFAULT_MAX_DIRECTORIES = 10_000
71
+ _IGNORE_HASH_MAX_BYTES = 256 * 1024
72
+ _IGNORE_HASH_RECENT_SECONDS = 5
73
+ _DEFAULT_LIST_MAX_ENTRIES = 50_000
74
+ _DEFAULT_LIST_MAX_BYTES = 4 * 1024 * 1024
75
+ _MAX_LIST_MAX_BYTES = 15_900_000
76
+ _DEFAULT_LIST_TIMEOUT_MS = 10_000
77
+
78
+
79
+ def resolve_path(cwd: str, path: str) -> str:
80
+ """Resolve ``~``, ``file://`` URLs, and relative paths the way the Node backend does."""
81
+
82
+ normalized = path
83
+ if normalized == "~":
84
+ normalized = os.path.expanduser("~")
85
+ elif normalized.startswith("~/"):
86
+ normalized = os.path.join(os.path.expanduser("~"), normalized[2:])
87
+ elif normalized.startswith("file://"):
88
+ try:
89
+ normalized = url2pathname(urlparse(normalized).path)
90
+ except ValueError:
91
+ pass
92
+ if os.path.isabs(normalized):
93
+ return os.path.abspath(normalized)
94
+ return os.path.abspath(os.path.join(cwd, normalized))
95
+
96
+
97
+ def _file_kind(stats: os.stat_result) -> str | None:
98
+ if stat.S_ISREG(stats.st_mode):
99
+ return "file"
100
+ if stat.S_ISDIR(stats.st_mode):
101
+ return "directory"
102
+ if stat.S_ISLNK(stats.st_mode):
103
+ return "symlink"
104
+ return None
105
+
106
+
107
+ def _file_info(path: str, stats: os.stat_result) -> Result[FileInfo, FileError]:
108
+ kind = _file_kind(stats)
109
+ if kind is None:
110
+ return err(FileError("invalid", "Unsupported file type", path))
111
+ return ok(
112
+ FileInfo(
113
+ name=os.path.basename(path),
114
+ path=path,
115
+ kind=kind,
116
+ size=stats.st_size,
117
+ mtimeMs=stats.st_mtime * 1000,
118
+ )
119
+ )
120
+
121
+
122
+ def _error_code(error: BaseException) -> FileErrorCode:
123
+ if isinstance(error, FileError):
124
+ return error.code
125
+ if isinstance(error, AbortError):
126
+ return "aborted"
127
+ if isinstance(error, FileNotFoundError):
128
+ return "not_found"
129
+ if isinstance(error, PermissionError):
130
+ return "permission_denied"
131
+ if isinstance(error, NotADirectoryError):
132
+ return "not_directory"
133
+ if isinstance(error, IsADirectoryError):
134
+ return "is_directory"
135
+ if getattr(error, "errno", None) in (errno.ELOOP, errno.EMLINK):
136
+ return "invalid"
137
+ if isinstance(error, (ValueError, OverflowError)):
138
+ return "invalid"
139
+ return "unknown"
140
+
141
+
142
+ def to_file_error(error: object, fallback_path: str | None = None) -> FileError:
143
+ if isinstance(error, FileError):
144
+ return error
145
+ cause = to_error(error)
146
+ path = getattr(error, "filename", None)
147
+ if not isinstance(path, str):
148
+ path = fallback_path
149
+ return FileError(_error_code(cause), str(cause), path, cause)
150
+
151
+
152
+ def _abort_result(context: Context, path: str | None = None) -> Result[Any, FileError] | None:
153
+ signal = context.abort_signal
154
+ if signal is not None and signal.aborted:
155
+ return err(FileError("aborted", "aborted", path))
156
+ return None
157
+
158
+
159
+ def _encode(content: str | bytes) -> bytes:
160
+ return content.encode() if isinstance(content, str) else content
161
+
162
+
163
+ def _closed(what: str, path: str) -> Result[Any, FileError]:
164
+ return err(FileError("invalid", f"{what} is closed", path))
165
+
166
+
167
+ class _TextLineReader:
168
+ def __init__(self, path: str, file: Any) -> None:
169
+ self._path = path
170
+ self._file = file
171
+ self._decoder = StreamDecoder()
172
+ self._buffer = ""
173
+ self._offset = 0
174
+ self._ended = False
175
+ self._closed = False
176
+
177
+ async def read_line(self, context: Context) -> Result[TextLine | None, FileError]:
178
+ aborted = _abort_result(context, self._path)
179
+ if aborted is not None:
180
+ return aborted
181
+ if self._closed:
182
+ return _closed("Text line reader", self._path)
183
+ try:
184
+ while True:
185
+ newline = self._buffer.find("\n")
186
+ if newline != -1:
187
+ text = self._buffer[:newline]
188
+ self._buffer = self._buffer[newline + 1 :]
189
+ return ok(TextLine(text=text, terminated=True))
190
+ if self._ended:
191
+ if self._buffer == "":
192
+ return ok(None)
193
+ text = self._buffer
194
+ self._buffer = ""
195
+ return ok(TextLine(text=text, terminated=False))
196
+
197
+ # Explicit positions allow an aborted read to be retried without skipping bytes.
198
+ chunk = await asyncio.to_thread(os.pread, self._file.fileno(), _LINE_READ_CHUNK, self._offset)
199
+ after_read = _abort_result(context, self._path)
200
+ if after_read is not None:
201
+ return after_read
202
+ self._offset += len(chunk)
203
+ if not chunk:
204
+ self._buffer += self._decoder.decode()
205
+ self._ended = True
206
+ else:
207
+ self._buffer += self._decoder.decode(chunk)
208
+ except OSError as error:
209
+ return err(to_file_error(error, self._path))
210
+
211
+ async def close(self, context: Context) -> None:
212
+ del context
213
+ if self._closed:
214
+ return
215
+ self._closed = True
216
+ self._buffer = ""
217
+ try:
218
+ self._file.close()
219
+ except OSError:
220
+ pass
221
+
222
+
223
+ class _BinaryReader:
224
+ def __init__(self, path: str, fd: int, info: FileInfo) -> None:
225
+ self._path = path
226
+ self._fd = fd
227
+ self._info = info
228
+ self._closed = False
229
+
230
+ async def info(self, context: Context) -> Result[FileInfo, FileError]:
231
+ aborted = _abort_result(context, self._path)
232
+ if aborted is not None:
233
+ return aborted
234
+ if self._closed:
235
+ return _closed("Binary reader", self._path)
236
+ try:
237
+ return _file_info(self._path, os.fstat(self._fd))
238
+ except OSError as error:
239
+ return err(to_file_error(error, self._path))
240
+
241
+ async def read(self, offset: int, length: int, context: Context) -> Result[bytes, FileError]:
242
+ aborted = _abort_result(context, self._path)
243
+ if aborted is not None:
244
+ return aborted
245
+ if self._closed:
246
+ return _closed("Binary reader", self._path)
247
+ if (
248
+ isinstance(offset, bool)
249
+ or not isinstance(offset, int)
250
+ or isinstance(length, bool)
251
+ or not isinstance(length, int)
252
+ or offset < 0
253
+ or length < 0
254
+ or offset > _MAX_SAFE_INTEGER
255
+ or length > _MAX_SAFE_INTEGER
256
+ ):
257
+ return err(
258
+ FileError(
259
+ "invalid",
260
+ "Offset and length must be non-negative safe integers",
261
+ self._path,
262
+ )
263
+ )
264
+ chunks: list[bytes] = []
265
+ total = 0
266
+ try:
267
+ while total < length:
268
+ chunk = await asyncio.to_thread(
269
+ os.pread, self._fd, min(length - total, _BINARY_READ_CHUNK), offset + total
270
+ )
271
+ after_read = _abort_result(context, self._path)
272
+ if after_read is not None:
273
+ return after_read
274
+ if not chunk:
275
+ break
276
+ chunks.append(chunk)
277
+ total += len(chunk)
278
+ except OSError as error:
279
+ return err(to_file_error(error, self._path))
280
+ return ok(b"".join(chunks))
281
+
282
+ async def scan_lines(self, options: dict[str, Any], context: Context) -> Result[LineScan, FileError]:
283
+ aborted = _abort_result(context, self._path)
284
+ if aborted is not None:
285
+ return aborted
286
+ if self._closed:
287
+ return _closed("Binary reader", self._path)
288
+ try:
289
+ start_line = options.get("start_line", options.get("startLine"))
290
+ end_line = options.get("end_line", options.get("endLine"))
291
+ scanner = LineScanner(cast(int, start_line), cast(int | None, end_line))
292
+ except (KeyError, TypeError, ValueError):
293
+ return err(FileError("invalid", "Invalid line range", self._path))
294
+ try:
295
+ position = 0
296
+ while True:
297
+ chunk = await asyncio.to_thread(os.pread, self._fd, _LINE_READ_CHUNK, position)
298
+ after_read = _abort_result(context, self._path)
299
+ if after_read is not None:
300
+ return after_read
301
+ if not chunk:
302
+ return ok(scanner.finish())
303
+ scanner.push(chunk)
304
+ position += len(chunk)
305
+ except OSError as error:
306
+ return err(to_file_error(error, self._path))
307
+
308
+ async def close(self, context: Context) -> None:
309
+ del context
310
+ if self._closed:
311
+ return
312
+ self._closed = True
313
+ try:
314
+ os.close(self._fd)
315
+ except OSError:
316
+ pass
317
+
318
+
319
+ class _DirReader:
320
+ def __init__(self, path: str, iterator: Any) -> None:
321
+ self._path = path
322
+ self._iterator = iterator
323
+ self._done = False
324
+ self._closed = False
325
+
326
+ async def next(self, max_entries: int, context: Context) -> Result[DirPage, FileError]:
327
+ aborted = _abort_result(context, self._path)
328
+ if aborted is not None:
329
+ return aborted
330
+ if self._closed:
331
+ return _closed("Directory reader", self._path)
332
+ if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries <= 0:
333
+ return err(FileError("invalid", "maxEntries must be a positive safe integer", self._path))
334
+ entries: list[FileInfo] = []
335
+ try:
336
+ while not self._done and len(entries) < max_entries:
337
+ entry = next(self._iterator, None)
338
+ if entry is None:
339
+ self._done = True
340
+ break
341
+ loop_abort = _abort_result(context, self._path)
342
+ if loop_abort is not None:
343
+ return loop_abort
344
+ entry_path = os.path.join(self._path, entry.name)
345
+ try:
346
+ info = _file_info(entry_path, os.lstat(entry_path))
347
+ except FileNotFoundError:
348
+ # Removed between enumeration and lstat: not part of the listing any more.
349
+ continue
350
+ except OSError as error:
351
+ return err(to_file_error(error, entry_path))
352
+ if isinstance(info, Ok):
353
+ entries.append(info.value)
354
+ return ok(DirPage(entries=entries, done=self._done))
355
+ except OSError as error:
356
+ return err(to_file_error(error, self._path))
357
+
358
+ async def close(self, context: Context) -> None:
359
+ del context
360
+ if self._closed:
361
+ return
362
+ self._closed = True
363
+ try:
364
+ self._iterator.close()
365
+ except OSError:
366
+ pass
367
+
368
+
369
+ @dataclass(frozen=True, slots=True)
370
+ class _WatchTarget:
371
+ path: str
372
+ recursive: bool
373
+ hidden: bool
374
+ names: frozenset[str]
375
+
376
+
377
+ @dataclass(frozen=True, slots=True)
378
+ class _Entry:
379
+ kind: str
380
+ dev: int
381
+ ino: int
382
+ size: int
383
+ mtime_ns: int
384
+ digest: str | None = None
385
+
386
+
387
+ class _BudgetExceeded(Exception):
388
+ pass
389
+
390
+
391
+ def _entry_of(stats: os.stat_result, *, identity_only: bool = False, digest: str | None = None) -> _Entry:
392
+ kind = _file_kind(stats) or "other"
393
+ if identity_only:
394
+ return _Entry(kind, stats.st_dev, stats.st_ino, 0, 0, None)
395
+ return _Entry(
396
+ kind,
397
+ stats.st_dev,
398
+ stats.st_ino,
399
+ stats.st_size if kind == "file" else 0,
400
+ stats.st_mtime_ns if kind == "file" else 0,
401
+ digest,
402
+ )
403
+
404
+
405
+ def _ancestors(path: str) -> list[str]:
406
+ result: list[str] = []
407
+ current = os.path.dirname(path)
408
+ while True:
409
+ result.append(current)
410
+ parent = os.path.dirname(current)
411
+ if parent == current:
412
+ return result
413
+ current = parent
414
+
415
+
416
+ def _within(path: str, ancestor: str) -> bool:
417
+ return path == ancestor or path.startswith(ancestor + os.sep)
418
+
419
+
420
+ def _excluded(target: _WatchTarget, name: str) -> bool:
421
+ return (target.hidden and name.startswith(".")) or name in target.names
422
+
423
+
424
+ class PosixWatchOptions(dict[str, Any]):
425
+ """Options for :class:`PosixExecutionEnv` watches.
426
+
427
+ ``poll_interval_ms`` sets the snapshot interval and ``max_directories`` bounds the watched
428
+ tree. ``mode`` is accepted for compatibility; the portable backend always polls.
429
+ """
430
+
431
+
432
+ class PosixFileWatcher:
433
+ """Polling watcher: snapshots the watched tree and reports differences.
434
+
435
+ Polling is the portable choice: it sees creations, replacements, and renamed ancestors without
436
+ depending on a platform's event stream, and mode ``polling`` is one of the documented modes.
437
+ """
438
+
439
+ mode: Literal["native", "polling"] = "polling"
440
+
441
+ def __init__(
442
+ self,
443
+ targets: Sequence[_WatchTarget],
444
+ on_change: Callable[[WatchChange], None],
445
+ *,
446
+ poll_interval_ms: int = _DEFAULT_POLL_INTERVAL_MS,
447
+ max_directories: int = _DEFAULT_MAX_DIRECTORIES,
448
+ ) -> None:
449
+ self._targets = list(targets)
450
+ self._on_change = on_change
451
+ self._poll_interval = max(poll_interval_ms, 1) / 1000
452
+ self._max_directories = max_directories
453
+ self._snapshot: dict[str, _Entry] = {}
454
+ self._task: asyncio.Task[None] | None = None
455
+ self._closed = False
456
+
457
+ @classmethod
458
+ async def open(
459
+ cls,
460
+ targets: Sequence[_WatchTarget],
461
+ on_change: Callable[[WatchChange], None],
462
+ *,
463
+ poll_interval_ms: int = _DEFAULT_POLL_INTERVAL_MS,
464
+ max_directories: int = _DEFAULT_MAX_DIRECTORIES,
465
+ ) -> PosixFileWatcher:
466
+ watcher = cls(
467
+ targets,
468
+ on_change,
469
+ poll_interval_ms=poll_interval_ms,
470
+ max_directories=max_directories,
471
+ )
472
+ try:
473
+ watcher._snapshot = await watcher._scan()
474
+ except _BudgetExceeded as error:
475
+ raise FileError("invalid", str(error)) from None
476
+ watcher._task = asyncio.create_task(watcher._run())
477
+ return watcher
478
+
479
+ def _deliver(self, change: WatchChange) -> None:
480
+ if self._closed:
481
+ return
482
+ try:
483
+ self._on_change(change)
484
+ except Exception:
485
+ # A throwing callback must not stop watching.
486
+ pass
487
+
488
+ async def _run(self) -> None:
489
+ try:
490
+ while not self._closed:
491
+ await asyncio.sleep(self._poll_interval)
492
+ if self._closed:
493
+ return
494
+ try:
495
+ snapshot = await self._scan()
496
+ except _BudgetExceeded as error:
497
+ self._deliver(WatchError(error=FileError("invalid", str(error))))
498
+ return
499
+ except (FileError, OSError) as error:
500
+ self._deliver(WatchError(error=to_file_error(error)))
501
+ return
502
+ changed = self._diff(self._snapshot, snapshot)
503
+ self._snapshot = snapshot
504
+ if changed:
505
+ self._deliver(WatchPaths(paths=sorted(changed)))
506
+ except asyncio.CancelledError:
507
+ return
508
+
509
+ def _diff(self, previous: dict[str, _Entry], next_snapshot: dict[str, _Entry]) -> set[str]:
510
+ changed: set[str] = set()
511
+ for path, entry in next_snapshot.items():
512
+ before = previous.get(path)
513
+ if before is None or before != entry:
514
+ changed.add(self._reported(path))
515
+ for path in previous:
516
+ if path not in next_snapshot:
517
+ changed.add(self._reported(path))
518
+ return changed
519
+
520
+ def _reported(self, path: str) -> str:
521
+ if any(_within(path, target.path) for target in self._targets):
522
+ return path
523
+ for target in self._targets:
524
+ if _within(target.path, path):
525
+ return target.path
526
+ return path
527
+
528
+ async def _scan(self) -> dict[str, _Entry]:
529
+ snapshot: dict[str, _Entry] = {}
530
+ counted: set[str] = set()
531
+ traversed: set[tuple[int, str]] = set()
532
+
533
+ def count(directory: str) -> None:
534
+ counted.add(directory)
535
+ if len(counted) > self._max_directories:
536
+ raise _BudgetExceeded(f"Watched paths exceed {self._max_directories} directories")
537
+
538
+ async def record(path: str, stats: os.stat_result) -> None:
539
+ digest: str | None = None
540
+ if (
541
+ stat.S_ISREG(stats.st_mode)
542
+ and stats.st_size <= _IGNORE_HASH_MAX_BYTES
543
+ and time.time() - stats.st_mtime < _IGNORE_HASH_RECENT_SECONDS
544
+ ):
545
+ try:
546
+ digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()
547
+ except OSError:
548
+ digest = None
549
+ snapshot[path] = _entry_of(stats, digest=digest)
550
+
551
+ async def scan_directory(index: int, target: _WatchTarget, directory: str) -> None:
552
+ key = (index, directory)
553
+ if key in traversed:
554
+ return
555
+ traversed.add(key)
556
+ try:
557
+ names = sorted(os.listdir(directory), key=os.fsencode)
558
+ except FileNotFoundError:
559
+ return
560
+ except (PermissionError, NotADirectoryError):
561
+ # The watched directory itself must be readable; below it, unreadable directories are skipped.
562
+ if directory == target.path:
563
+ raise
564
+ return
565
+ for name in names:
566
+ if _excluded(target, name):
567
+ continue
568
+ path = os.path.join(directory, name)
569
+ if path in snapshot:
570
+ kind = snapshot[path].kind
571
+ else:
572
+ try:
573
+ stats = os.lstat(path)
574
+ except OSError:
575
+ continue
576
+ await record(path, stats)
577
+ kind = snapshot[path].kind
578
+ if target.recursive and kind == "directory":
579
+ count(path)
580
+ await scan_directory(index, target, path)
581
+
582
+ for index, target in enumerate(self._targets):
583
+ for ancestor in _ancestors(target.path):
584
+ if ancestor in snapshot:
585
+ continue
586
+ try:
587
+ stats = os.lstat(ancestor)
588
+ except OSError:
589
+ continue
590
+ snapshot[ancestor] = _entry_of(stats, identity_only=True)
591
+ try:
592
+ stats = os.stat(target.path)
593
+ except PermissionError:
594
+ raise
595
+ except OSError:
596
+ continue
597
+ await record(target.path, stats)
598
+ if stat.S_ISDIR(stats.st_mode):
599
+ count(target.path)
600
+ await scan_directory(index, target, target.path)
601
+ return snapshot
602
+
603
+ async def close(self, context: Context) -> None:
604
+ del context
605
+ if self._closed:
606
+ return
607
+ self._closed = True
608
+ task = self._task
609
+ self._task = None
610
+ if task is not None and not task.done():
611
+ task.cancel()
612
+ try:
613
+ await task
614
+ except asyncio.CancelledError:
615
+ pass
616
+
617
+
618
+ def _gitignore_matches(relative: str, pattern: str) -> bool:
619
+ """Match one gitignore pattern; ``*`` and ``?`` do not cross ``/`` and ``**`` does."""
620
+
621
+ parts: list[str] = ["^"]
622
+ index = 0
623
+ while index < len(pattern):
624
+ character = pattern[index]
625
+ if character == "*":
626
+ if index + 1 < len(pattern) and pattern[index + 1] == "*":
627
+ parts.append(".*")
628
+ index += 2
629
+ continue
630
+ parts.append("[^/]*")
631
+ elif character == "?":
632
+ parts.append("[^/]")
633
+ elif character == "[":
634
+ end = pattern.find("]", index + 1)
635
+ if end == -1:
636
+ parts.append(re.escape(character))
637
+ else:
638
+ body = pattern[index + 1 : end]
639
+ if body.startswith("!"):
640
+ body = "^" + body[1:]
641
+ parts.append("[" + body + "]")
642
+ index = end
643
+ else:
644
+ parts.append(re.escape(character))
645
+ index += 1
646
+ parts.append("$")
647
+ return re.match("".join(parts), relative) is not None
648
+
649
+
650
+ @dataclass(frozen=True, slots=True)
651
+ class _IgnoreRule:
652
+ base: str
653
+ pattern: str
654
+ negated: bool
655
+ directory_only: bool
656
+ anchored: bool
657
+
658
+ def matches(self, path: str, kind: str) -> bool:
659
+ if self.directory_only and kind != "directory":
660
+ return False
661
+ relative = os.path.relpath(path, self.base)
662
+ if relative == os.pardir or relative.startswith(os.pardir + os.sep):
663
+ return False
664
+ relative = relative.replace(os.sep, "/")
665
+ if self.anchored or "/" in self.pattern:
666
+ return _gitignore_matches(relative, self.pattern)
667
+ return any(_gitignore_matches(part, self.pattern) for part in relative.split("/"))
668
+
669
+
670
+ def _read_ignore_file(path: str, base: str) -> list[_IgnoreRule]:
671
+ rules: list[_IgnoreRule] = []
672
+ try:
673
+ lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
674
+ except OSError:
675
+ return rules
676
+ for line in lines:
677
+ line = line.rstrip("\r")
678
+ if line == "" or line.startswith("#"):
679
+ continue
680
+ negated = line.startswith("!")
681
+ if negated:
682
+ line = line[1:]
683
+ if line == "":
684
+ continue
685
+ directory_only = line.endswith("/")
686
+ if directory_only:
687
+ line = line[:-1]
688
+ anchored = line.startswith("/")
689
+ if anchored:
690
+ line = line[1:]
691
+ if line == "":
692
+ continue
693
+ rules.append(_IgnoreRule(base, line, negated, directory_only, anchored))
694
+ return rules
695
+
696
+
697
+ def _ignored(path: str, kind: str, rules: Sequence[_IgnoreRule]) -> bool:
698
+ ignored = False
699
+ for rule in rules:
700
+ if rule.matches(path, kind):
701
+ ignored = not rule.negated
702
+ return ignored
703
+
704
+
705
+ def _find_git_root(path: str) -> str | None:
706
+ current = os.path.abspath(path)
707
+ while True:
708
+ if os.path.exists(os.path.join(current, ".git")):
709
+ return current
710
+ parent = os.path.dirname(current)
711
+ if parent == current:
712
+ return None
713
+ current = parent
714
+
715
+
716
+ def _ignore_rules_for(root: str) -> list[_IgnoreRule]:
717
+ git_root = _find_git_root(root)
718
+ if git_root is None:
719
+ return []
720
+ rules = _read_ignore_file(os.path.join(git_root, ".git", "info", "exclude"), git_root)
721
+ relative = os.path.relpath(root, git_root)
722
+ components = [] if relative == os.curdir else relative.split(os.sep)
723
+ current = git_root
724
+ rules.extend(_read_ignore_file(os.path.join(current, ".gitignore"), current))
725
+ for component in components:
726
+ current = os.path.join(current, component)
727
+ rules.extend(_read_ignore_file(os.path.join(current, ".gitignore"), current))
728
+ return rules
729
+
730
+
731
+ def _list_option(options: dict[str, Any] | None, name: str, default: Any) -> Any:
732
+ if options is None:
733
+ return default
734
+ return options.get(name, default)
735
+
736
+
737
+ def _listing_int(value: object, *, maximum: int | None = None) -> int | None:
738
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
739
+ return None
740
+ if maximum is not None and value > maximum:
741
+ return None
742
+ return value
743
+
744
+
745
+ class _SpillWriter:
746
+ """Write the complete raw output once either threshold is crossed."""
747
+
748
+ def __init__(self, env: PosixExecutionEnv, context: Context, options: dict[str, Any]) -> None:
749
+ self._env = env
750
+ self._context = context
751
+ self._after_bytes = int(options.get("after_bytes", 0))
752
+ self._after_lines = int(options.get("after_lines", 0))
753
+ self._prefix: list[bytes] = []
754
+ self._seen_bytes = 0
755
+ self._seen_newlines = 0
756
+ self._started = False
757
+ self._finished = False
758
+ self._error: ExecutionError | None = None
759
+ self._path: str | None = None
760
+ self._file: Any = None
761
+ self._lock = asyncio.Lock()
762
+
763
+ @property
764
+ def path(self) -> str | None:
765
+ return self._path
766
+
767
+ @property
768
+ def error(self) -> ExecutionError | None:
769
+ return self._error
770
+
771
+ @property
772
+ def active(self) -> bool:
773
+ return self._started and not self._finished
774
+
775
+ async def feed(self, chunk: bytes) -> None:
776
+ if not chunk or self._finished or self._error is not None:
777
+ return
778
+ async with self._lock:
779
+ if self._started:
780
+ await self._write(chunk)
781
+ return
782
+ self._seen_bytes += len(chunk)
783
+ self._seen_newlines += chunk.count(b"\n")
784
+ lines = self._seen_newlines + (0 if chunk.endswith(b"\n") else 1)
785
+ if self._seen_bytes <= self._after_bytes and lines <= self._after_lines:
786
+ self._prefix.append(chunk)
787
+ return
788
+ prefix = self._prefix
789
+ self._prefix = []
790
+ await self._start()
791
+ for part in prefix:
792
+ await self._write(part)
793
+ await self._write(chunk)
794
+
795
+ async def _start(self) -> None:
796
+ self._started = True
797
+ created = await self._env.create_temp_file({"prefix": "pi-output-", "suffix": ".log"}, self._context)
798
+ if isinstance(created, Err):
799
+ self._error = ExecutionError(
800
+ "unknown",
801
+ f"Failed to preserve complete shell output: {created.error}",
802
+ created.error,
803
+ )
804
+ return
805
+ self._path = created.value
806
+ try:
807
+ self._file = open(created.value, "ab")
808
+ except OSError as error:
809
+ self._error = ExecutionError(
810
+ "unknown",
811
+ f"Failed to preserve complete shell output: {error}",
812
+ error,
813
+ )
814
+
815
+ async def _write(self, chunk: bytes) -> None:
816
+ if self._error is not None or self._file is None:
817
+ return
818
+ try:
819
+ self._file.write(chunk)
820
+ except OSError as error:
821
+ self._error = ExecutionError(
822
+ "unknown",
823
+ f"Failed to preserve complete shell output: {error}",
824
+ error,
825
+ )
826
+
827
+ async def finish(self) -> None:
828
+ async with self._lock:
829
+ if self._finished:
830
+ return
831
+ self._finished = True
832
+ if self._file is not None:
833
+ try:
834
+ self._file.close()
835
+ except OSError as error:
836
+ if self._error is None:
837
+ self._error = ExecutionError(
838
+ "unknown",
839
+ f"Failed to preserve complete shell output: {error}",
840
+ error,
841
+ )
842
+
843
+
844
+ def _resolve_timeout(timeout: object) -> Result[int | None, ExecutionError]:
845
+ if timeout is None:
846
+ return ok(None)
847
+ if (
848
+ isinstance(timeout, bool)
849
+ or not isinstance(timeout, (int, float))
850
+ or not math.isfinite(timeout)
851
+ or timeout <= 0
852
+ ):
853
+ return err(ExecutionError("timeout", "Invalid timeout: must be a finite number of seconds"))
854
+ timeout_ms = timeout * 1000
855
+ if timeout_ms > _MAX_TIMEOUT_MS:
856
+ return err(
857
+ ExecutionError(
858
+ "timeout",
859
+ f"Invalid timeout: maximum is {_MAX_TIMEOUT_MS / 1000} seconds",
860
+ )
861
+ )
862
+ return ok(int(timeout_ms))
863
+
864
+
865
+ def _shell_config(shell_path: str | None) -> Result[list[str], ExecutionError]:
866
+ if shell_path is not None:
867
+ if os.path.exists(shell_path):
868
+ return ok([shell_path, "-c"])
869
+ return err(ExecutionError("shell_unavailable", f"Custom shell path not found: {shell_path}"))
870
+ if os.path.exists("/bin/bash"):
871
+ return ok(["/bin/bash", "-c"])
872
+ found = shutil.which("bash")
873
+ if found:
874
+ return ok([found, "-c"])
875
+ return ok(["sh", "-c"])
876
+
877
+
878
+ def _shell_env(
879
+ base: dict[str, str] | None,
880
+ extra: dict[str, str] | None,
881
+ inherit: bool,
882
+ ) -> dict[str, str]:
883
+ if not inherit:
884
+ environment: dict[str, str] = {}
885
+ else:
886
+ environment = dict(os.environ)
887
+ if base:
888
+ environment.update({key: value for key, value in base.items() if value is not None})
889
+ if extra:
890
+ environment.update({key: value for key, value in extra.items() if value is not None})
891
+ return environment
892
+
893
+
894
+ def _kill_process(process: asyncio.subprocess.Process) -> None:
895
+ if process.returncode is not None:
896
+ return
897
+ try:
898
+ os.killpg(process.pid, signal.SIGKILL)
899
+ except (ProcessLookupError, PermissionError, AttributeError):
900
+ try:
901
+ process.kill()
902
+ except ProcessLookupError:
903
+ pass
904
+
905
+
906
+ class PosixExecutionEnv(ExecutionEnv):
907
+ """The POSIX filesystem and shell for the local machine."""
908
+
909
+ id = "posix:local"
910
+
911
+ def __init__(
912
+ self,
913
+ *,
914
+ cwd: str | None = None,
915
+ shell_path: str | None = None,
916
+ shell_env: dict[str, str] | None = None,
917
+ watch: PosixWatchOptions | dict[str, Any] | None = None,
918
+ ) -> None:
919
+ self.cwd = os.getcwd() if cwd is None else cwd
920
+ self.shell_path = shell_path
921
+ self.shell_env = shell_env
922
+ watch_options: dict[str, Any] = watch or {}
923
+ self._poll_interval_ms = int(watch_options.get("poll_interval_ms", _DEFAULT_POLL_INTERVAL_MS))
924
+ self._max_directories = int(watch_options.get("max_directories", _DEFAULT_MAX_DIRECTORIES))
925
+ self._children: set[asyncio.subprocess.Process] = set()
926
+
927
+ async def absolute_path(self, path: str, context: Context) -> Result[str, FileError]:
928
+ del context
929
+ return ok(resolve_path(self.cwd, path))
930
+
931
+ async def join_path(self, parts: Sequence[str], context: Context) -> Result[str, FileError]:
932
+ del context
933
+ return ok(os.path.normpath(os.path.join(*parts)))
934
+
935
+ async def read_text_file(self, path: str, context: Context) -> Result[str, FileError]:
936
+ resolved = resolve_path(self.cwd, path)
937
+ aborted = _abort_result(context, resolved)
938
+ if aborted is not None:
939
+ return aborted
940
+ try:
941
+ data = await asyncio.to_thread(Path(resolved).read_bytes)
942
+ return ok(data.decode("utf-8", "replace"))
943
+ except OSError as error:
944
+ return err(to_file_error(error, resolved))
945
+
946
+ async def write_file(self, path: str, content: str | bytes, context: Context) -> Result[None, FileError]:
947
+ resolved = resolve_path(self.cwd, path)
948
+ aborted = _abort_result(context, resolved)
949
+ if aborted is not None:
950
+ return aborted
951
+ try:
952
+ os.makedirs(os.path.dirname(resolved) or self.cwd, exist_ok=True)
953
+ after_mkdir = _abort_result(context, resolved)
954
+ if after_mkdir is not None:
955
+ return after_mkdir
956
+ await asyncio.to_thread(Path(resolved).write_bytes, _encode(content))
957
+ return ok(None)
958
+ except OSError as error:
959
+ return err(to_file_error(error, resolved))
960
+
961
+ async def append_file(self, path: str, content: str | bytes, context: Context) -> Result[None, FileError]:
962
+ resolved = resolve_path(self.cwd, path)
963
+ aborted = _abort_result(context, resolved)
964
+ if aborted is not None:
965
+ return aborted
966
+ try:
967
+ os.makedirs(os.path.dirname(resolved) or self.cwd, exist_ok=True)
968
+ after_mkdir = _abort_result(context, resolved)
969
+ if after_mkdir is not None:
970
+ return after_mkdir
971
+
972
+ def append() -> None:
973
+ with open(resolved, "ab") as handle:
974
+ handle.write(_encode(content))
975
+
976
+ await asyncio.to_thread(append)
977
+ return _abort_result(context, resolved) or ok(None)
978
+ except OSError as error:
979
+ return err(to_file_error(error, resolved))
980
+
981
+ async def truncate_file(self, path: str, size: int, context: Context) -> Result[None, FileError]:
982
+ resolved = resolve_path(self.cwd, path)
983
+ aborted = _abort_result(context, resolved)
984
+ if aborted is not None:
985
+ return aborted
986
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0 or size > _MAX_SAFE_INTEGER:
987
+ return err(FileError("invalid", "File size must be a non-negative safe integer", resolved))
988
+ try:
989
+ fd = os.open(resolved, os.O_RDWR)
990
+ try:
991
+ os.ftruncate(fd, size)
992
+ finally:
993
+ os.close(fd)
994
+ return _abort_result(context, resolved) or ok(None)
995
+ except OSError as error:
996
+ return err(to_file_error(error, resolved))
997
+
998
+ async def flush_file(self, path: str, context: Context) -> Result[None, FileError]:
999
+ resolved = resolve_path(self.cwd, path)
1000
+ aborted = _abort_result(context, resolved)
1001
+ if aborted is not None:
1002
+ return aborted
1003
+ try:
1004
+ fd = os.open(resolved, os.O_RDWR)
1005
+ try:
1006
+ if stat.S_ISDIR(os.fstat(fd).st_mode):
1007
+ return err(FileError("is_directory", "Is a directory", resolved))
1008
+ os.fsync(fd)
1009
+ finally:
1010
+ os.close(fd)
1011
+ return _abort_result(context, resolved) or ok(None)
1012
+ except OSError as error:
1013
+ return err(to_file_error(error, resolved))
1014
+
1015
+ async def rename_file(
1016
+ self, source_path: str, destination_path: str, context: Context
1017
+ ) -> Result[None, FileError]:
1018
+ source = resolve_path(self.cwd, source_path)
1019
+ destination = resolve_path(self.cwd, destination_path)
1020
+ aborted = _abort_result(context, destination)
1021
+ if aborted is not None:
1022
+ return aborted
1023
+ try:
1024
+ os.replace(source, destination)
1025
+ return ok(None)
1026
+ except OSError as error:
1027
+ return err(to_file_error(error, source))
1028
+
1029
+ async def file_info(self, path: str, context: Context) -> Result[FileInfo, FileError]:
1030
+ resolved = resolve_path(self.cwd, path)
1031
+ aborted = _abort_result(context, resolved)
1032
+ if aborted is not None:
1033
+ return aborted
1034
+ try:
1035
+ return _file_info(resolved, os.lstat(resolved))
1036
+ except OSError as error:
1037
+ return err(to_file_error(error, resolved))
1038
+
1039
+ async def list_dir(self, path: str, context: Context) -> Result[list[FileInfo], FileError]:
1040
+ resolved = resolve_path(self.cwd, path)
1041
+ aborted = _abort_result(context, resolved)
1042
+ if aborted is not None:
1043
+ return aborted
1044
+ infos: list[FileInfo] = []
1045
+ try:
1046
+ names = await asyncio.to_thread(os.listdir, resolved)
1047
+ for name in names:
1048
+ loop_abort = _abort_result(context, resolved)
1049
+ if loop_abort is not None:
1050
+ return loop_abort
1051
+ entry_path = os.path.join(resolved, name)
1052
+ try:
1053
+ info = _file_info(entry_path, os.lstat(entry_path))
1054
+ except FileNotFoundError:
1055
+ continue
1056
+ if isinstance(info, Ok):
1057
+ infos.append(info.value)
1058
+ return ok(infos)
1059
+ except OSError as error:
1060
+ return err(to_file_error(error, resolved))
1061
+
1062
+ async def canonical_path(self, path: str, context: Context) -> Result[str, FileError]:
1063
+ resolved = resolve_path(self.cwd, path)
1064
+ aborted = _abort_result(context, resolved)
1065
+ if aborted is not None:
1066
+ return aborted
1067
+ try:
1068
+ return ok(os.path.realpath(resolved))
1069
+ except OSError as error:
1070
+ return err(to_file_error(error, resolved))
1071
+
1072
+ async def exists(self, path: str, context: Context) -> Result[bool, FileError]:
1073
+ result = await self.file_info(path, context)
1074
+ if isinstance(result, Ok):
1075
+ return ok(True)
1076
+ if result.error.code == "not_found":
1077
+ return ok(False)
1078
+ return err(result.error)
1079
+
1080
+ async def create_dir(
1081
+ self, path: str, options: dict[str, Any] | None, context: Context
1082
+ ) -> Result[None, FileError]:
1083
+ resolved = resolve_path(self.cwd, path)
1084
+ aborted = _abort_result(context, resolved)
1085
+ if aborted is not None:
1086
+ return aborted
1087
+ recursive = True if options is None else options.get("recursive", True)
1088
+ try:
1089
+ if recursive:
1090
+ os.makedirs(resolved, exist_ok=True)
1091
+ else:
1092
+ os.mkdir(resolved)
1093
+ return ok(None)
1094
+ except OSError as error:
1095
+ return err(to_file_error(error, resolved))
1096
+
1097
+ async def remove(
1098
+ self, path: str, options: dict[str, Any] | None, context: Context
1099
+ ) -> Result[None, FileError]:
1100
+ resolved = resolve_path(self.cwd, path)
1101
+ aborted = _abort_result(context, resolved)
1102
+ if aborted is not None:
1103
+ return aborted
1104
+ recursive = bool(options and options.get("recursive"))
1105
+ force = bool(options and options.get("force"))
1106
+ try:
1107
+ if recursive and os.path.isdir(resolved) and not os.path.islink(resolved):
1108
+ shutil.rmtree(resolved, ignore_errors=False)
1109
+ else:
1110
+ os.remove(resolved)
1111
+ return ok(None)
1112
+ except FileNotFoundError as error:
1113
+ return ok(None) if force else err(to_file_error(error, resolved))
1114
+ except OSError as error:
1115
+ return err(to_file_error(error, resolved))
1116
+
1117
+ async def create_temp_dir(self, prefix: str | None, context: Context) -> Result[str, FileError]:
1118
+ aborted = _abort_result(context)
1119
+ if aborted is not None:
1120
+ return aborted
1121
+ try:
1122
+ return ok(tempfile.mkdtemp(prefix=prefix or "tmp-"))
1123
+ except OSError as error:
1124
+ return err(to_file_error(error))
1125
+
1126
+ async def create_temp_file(
1127
+ self, options: dict[str, Any] | None, context: Context
1128
+ ) -> Result[str, FileError]:
1129
+ directory = await self.create_temp_dir("tmp-", context)
1130
+ if isinstance(directory, Err):
1131
+ return directory
1132
+ prefix = "" if options is None else options.get("prefix", "")
1133
+ suffix = "" if options is None else options.get("suffix", "")
1134
+ path = os.path.join(directory.value, f"{prefix}{uuid.uuid4()}{suffix}")
1135
+ try:
1136
+ Path(path).write_bytes(b"")
1137
+ return ok(path)
1138
+ except OSError as error:
1139
+ return err(to_file_error(error, path))
1140
+
1141
+ async def open_text_line_reader(self, path: str, context: Context) -> Result[TextLineReader, FileError]:
1142
+ resolved = resolve_path(self.cwd, path)
1143
+ aborted = _abort_result(context, resolved)
1144
+ if aborted is not None:
1145
+ return aborted
1146
+ try:
1147
+ file = await asyncio.to_thread(open, resolved, "rb")
1148
+ after_open = _abort_result(context, resolved)
1149
+ if after_open is not None:
1150
+ file.close()
1151
+ return after_open
1152
+ return ok(_TextLineReader(resolved, file))
1153
+ except OSError as error:
1154
+ return err(to_file_error(error, resolved))
1155
+
1156
+ async def read_text_lines(
1157
+ self, path: str, options: dict[str, Any] | None, context: Context
1158
+ ) -> Result[list[str], FileError]:
1159
+ max_lines = None if options is None else options.get("max_lines")
1160
+ if max_lines is not None and max_lines <= 0:
1161
+ return ok([])
1162
+ opened = await self.open_text_line_reader(path, context)
1163
+ if isinstance(opened, Err):
1164
+ return opened
1165
+ lines: list[str] = []
1166
+ try:
1167
+ while max_lines is None or len(lines) < max_lines:
1168
+ line = await opened.value.read_line(context)
1169
+ if isinstance(line, Err):
1170
+ return line
1171
+ if line.value is None:
1172
+ break
1173
+ lines.append(line.value["text"])
1174
+ return ok(lines)
1175
+ finally:
1176
+ await opened.value.close(context)
1177
+
1178
+ async def read_binary_file(self, path: str, context: Context) -> Result[bytes, FileError]:
1179
+ resolved = resolve_path(self.cwd, path)
1180
+ aborted = _abort_result(context, resolved)
1181
+ if aborted is not None:
1182
+ return aborted
1183
+ try:
1184
+ return ok(await asyncio.to_thread(Path(resolved).read_bytes))
1185
+ except OSError as error:
1186
+ return err(to_file_error(error, resolved))
1187
+
1188
+ async def open_binary_reader(
1189
+ self, path: str, options: dict[str, Any] | None, context: Context
1190
+ ) -> Result[BinaryReader, FileError]:
1191
+ resolved = resolve_path(self.cwd, path)
1192
+ aborted = _abort_result(context, resolved)
1193
+ if aborted is not None:
1194
+ return aborted
1195
+ no_follow = bool(options and options.get("no_follow"))
1196
+ flags = os.O_RDONLY
1197
+ if hasattr(os, "O_NONBLOCK"):
1198
+ flags |= os.O_NONBLOCK
1199
+ if no_follow and hasattr(os, "O_NOFOLLOW"):
1200
+ flags |= os.O_NOFOLLOW
1201
+ fd: int | None = None
1202
+ try:
1203
+ if no_follow and not hasattr(os, "O_NOFOLLOW") and os.path.islink(resolved):
1204
+ return err(FileError("invalid", "Refusing to follow a symbolic link", resolved))
1205
+ fd = os.open(resolved, flags)
1206
+ stats = os.fstat(fd)
1207
+ if stat.S_ISDIR(stats.st_mode):
1208
+ os.close(fd)
1209
+ return err(
1210
+ FileError(
1211
+ "is_directory",
1212
+ "EISDIR: illegal operation on a directory, read",
1213
+ resolved,
1214
+ )
1215
+ )
1216
+ if not stat.S_ISREG(stats.st_mode):
1217
+ os.close(fd)
1218
+ return err(FileError("invalid", "Not a regular file", resolved))
1219
+ after_open = _abort_result(context, resolved)
1220
+ if after_open is not None:
1221
+ os.close(fd)
1222
+ return after_open
1223
+ info = _file_info(resolved, stats)
1224
+ if isinstance(info, Err):
1225
+ os.close(fd)
1226
+ return info
1227
+ return ok(_BinaryReader(resolved, fd, info.value))
1228
+ except OSError as error:
1229
+ if fd is not None:
1230
+ try:
1231
+ os.close(fd)
1232
+ except OSError:
1233
+ pass
1234
+ if no_follow and getattr(error, "errno", None) in (errno.ELOOP, errno.EMLINK):
1235
+ return err(FileError("invalid", "Refusing to follow a symbolic link", resolved, error))
1236
+ return err(to_file_error(error, resolved))
1237
+
1238
+ async def open_dir_reader(self, path: str, context: Context) -> Result[DirReader, FileError]:
1239
+ resolved = resolve_path(self.cwd, path)
1240
+ aborted = _abort_result(context, resolved)
1241
+ if aborted is not None:
1242
+ return aborted
1243
+ try:
1244
+ iterator = await asyncio.to_thread(os.scandir, resolved)
1245
+ after_open = _abort_result(context, resolved)
1246
+ if after_open is not None:
1247
+ iterator.close()
1248
+ return after_open
1249
+ return ok(_DirReader(resolved, iterator))
1250
+ except OSError as error:
1251
+ return err(to_file_error(error, resolved))
1252
+
1253
+ async def list_files(
1254
+ self, path: str, options: ListFilesOptions | None, context: Context
1255
+ ) -> Result[FileListing, FileError]:
1256
+ resolved = resolve_path(self.cwd, path)
1257
+ aborted = _abort_result(context, resolved)
1258
+ if aborted is not None:
1259
+ return aborted
1260
+ opts: dict[str, Any] = {} if options is None else dict(options)
1261
+ max_entries = _listing_int(_list_option(opts, "max_entries", _DEFAULT_LIST_MAX_ENTRIES))
1262
+ max_bytes = _listing_int(
1263
+ _list_option(opts, "max_bytes", _DEFAULT_LIST_MAX_BYTES),
1264
+ maximum=_MAX_LIST_MAX_BYTES,
1265
+ )
1266
+ timeout_ms = _listing_int(_list_option(opts, "timeout_ms", _DEFAULT_LIST_TIMEOUT_MS))
1267
+ if max_entries is None:
1268
+ return err(FileError("invalid", "maxEntries must be a non-negative safe integer", resolved))
1269
+ if max_bytes is None:
1270
+ return err(FileError("invalid", "maxBytes must be a bounded non-negative safe integer", resolved))
1271
+ if timeout_ms is None:
1272
+ return err(FileError("invalid", "timeoutMs must be a non-negative safe integer", resolved))
1273
+ hidden = bool(opts.get("hidden", True))
1274
+ use_gitignore = bool(opts.get("gitignore", True))
1275
+ exclude = set(opts.get("exclude", ()))
1276
+ try:
1277
+ stats = await asyncio.to_thread(os.stat, resolved)
1278
+ except OSError as error:
1279
+ return err(to_file_error(error, resolved))
1280
+ if not stat.S_ISDIR(stats.st_mode):
1281
+ return err(FileError("not_directory", "ENOTDIR: not a directory", resolved))
1282
+
1283
+ entries: list[dict[str, Any]] = []
1284
+ used_bytes = 0
1285
+ deadline = time.monotonic() + timeout_ms / 1000
1286
+ truncated = False
1287
+ root_rules = _ignore_rules_for(resolved) if use_gitignore else []
1288
+
1289
+ class _StopWalk(Exception):
1290
+ pass
1291
+
1292
+ def take(entry_path: str, kind: str) -> None:
1293
+ nonlocal used_bytes, truncated
1294
+ relative = os.path.relpath(entry_path, resolved).replace(os.sep, "/")
1295
+ cost = len(relative.encode()) + 2
1296
+ if len(entries) >= max_entries or used_bytes + cost > max_bytes or time.monotonic() > deadline:
1297
+ truncated = True
1298
+ raise _StopWalk
1299
+ entries.append({"path": relative, "kind": kind})
1300
+ used_bytes += cost
1301
+
1302
+ async def walk(directory: str, rules: list[_IgnoreRule]) -> None:
1303
+ if _abort_result(context, directory) is not None:
1304
+ raise AbortError()
1305
+ try:
1306
+ names = await asyncio.to_thread(os.listdir, directory)
1307
+ except FileNotFoundError:
1308
+ return
1309
+ except PermissionError:
1310
+ if directory == resolved:
1311
+ raise
1312
+ return
1313
+ for name in sorted(names, key=os.fsencode):
1314
+ if name == ".git":
1315
+ continue
1316
+ if not hidden and name.startswith("."):
1317
+ continue
1318
+ if name in exclude:
1319
+ continue
1320
+ entry_path = os.path.join(directory, name)
1321
+ try:
1322
+ entry_stats = os.lstat(entry_path)
1323
+ except OSError:
1324
+ continue
1325
+ kind = _file_kind(entry_stats)
1326
+ if kind is None:
1327
+ continue
1328
+ if use_gitignore and _ignored(entry_path, kind, rules):
1329
+ continue
1330
+ take(entry_path, kind)
1331
+ if kind == "directory" and not stat.S_ISLNK(entry_stats.st_mode):
1332
+ child_rules = rules
1333
+ if use_gitignore:
1334
+ child_rules = rules + _read_ignore_file(
1335
+ os.path.join(entry_path, ".gitignore"), entry_path
1336
+ )
1337
+ await walk(entry_path, child_rules)
1338
+
1339
+ try:
1340
+ await walk(resolved, root_rules)
1341
+ except _StopWalk:
1342
+ pass
1343
+ except AbortError:
1344
+ return err(FileError("aborted", "aborted", resolved))
1345
+ except OSError as error:
1346
+ return err(to_file_error(error, resolved))
1347
+ return ok(FileListing(entries=entries, truncated=truncated))
1348
+
1349
+ async def watch(
1350
+ self,
1351
+ targets: Sequence[WatchTarget],
1352
+ on_change: Callable[[WatchChange], None],
1353
+ context: Context,
1354
+ ) -> Result[FileWatcher, FileError]:
1355
+ aborted = _abort_result(context)
1356
+ if aborted is not None:
1357
+ return aborted
1358
+ resolved_targets = [
1359
+ _WatchTarget(
1360
+ resolve_path(self.cwd, target["path"]),
1361
+ target.get("recursive") is True,
1362
+ bool((target.get("exclude") or {}).get("hidden")),
1363
+ frozenset((target.get("exclude") or {}).get("names") or ()),
1364
+ )
1365
+ for target in targets
1366
+ ]
1367
+ try:
1368
+ watcher = await PosixFileWatcher.open(
1369
+ resolved_targets,
1370
+ on_change,
1371
+ poll_interval_ms=self._poll_interval_ms,
1372
+ max_directories=self._max_directories,
1373
+ )
1374
+ after_open = _abort_result(context)
1375
+ if after_open is not None:
1376
+ await watcher.close(context)
1377
+ return after_open
1378
+ return ok(watcher)
1379
+ except FileError as error:
1380
+ return err(error)
1381
+ except OSError as error:
1382
+ return err(to_file_error(error))
1383
+
1384
+ async def exec(
1385
+ self,
1386
+ command: str | Sequence[str],
1387
+ options: ShellExecOptions | None,
1388
+ context: Context,
1389
+ ) -> Result[ShellExecResult, ExecutionError]:
1390
+ opts: dict[str, Any] = {} if options is None else dict(options)
1391
+ signal_state = context.abort_signal
1392
+ if signal_state is not None and signal_state.aborted:
1393
+ return err(ExecutionError("aborted", "aborted"))
1394
+ timeout = _resolve_timeout(opts.get("timeout"))
1395
+ if isinstance(timeout, Err):
1396
+ return timeout
1397
+
1398
+ cwd = resolve_path(self.cwd, opts["cwd"]) if opts.get("cwd") else self.cwd
1399
+ if isinstance(command, str):
1400
+ config = _shell_config(self.shell_path)
1401
+ if isinstance(config, Err):
1402
+ return config
1403
+ argv = [*config.value, command]
1404
+ else:
1405
+ argv = list(command)
1406
+ if not argv:
1407
+ return err(ExecutionError("spawn_error", "Empty argv: no program to run"))
1408
+ if not os.path.isdir(cwd):
1409
+ return err(
1410
+ ExecutionError(
1411
+ "spawn_error",
1412
+ f"Working directory does not exist: {cwd}\nCannot execute bash commands.",
1413
+ )
1414
+ )
1415
+
1416
+ environment = _shell_env(
1417
+ self.shell_env,
1418
+ opts.get("env"),
1419
+ bool(opts.get("inherit_env", True)),
1420
+ )
1421
+ try:
1422
+ process = await asyncio.create_subprocess_exec(
1423
+ *argv,
1424
+ cwd=cwd,
1425
+ env=environment,
1426
+ stdin=asyncio.subprocess.DEVNULL,
1427
+ stdout=asyncio.subprocess.PIPE,
1428
+ stderr=asyncio.subprocess.PIPE,
1429
+ start_new_session=True,
1430
+ )
1431
+ except (OSError, ValueError) as error:
1432
+ return err(ExecutionError("spawn_error", str(error), error))
1433
+
1434
+ self._children.add(process)
1435
+ loop = asyncio.get_running_loop()
1436
+ timed_out = False
1437
+ callback_error: ExecutionError | None = None
1438
+ stream_error: ExecutionError | None = None
1439
+ settled = False
1440
+ spill = _SpillWriter(self, context, opts["spill"]) if opts.get("spill") else None
1441
+ on_output = opts.get("on_output")
1442
+ timeout_handle: asyncio.TimerHandle | None = None
1443
+ remove_abort: Callable[[], None] | None = None
1444
+
1445
+ def emit(text: str, stream: str) -> None:
1446
+ nonlocal callback_error
1447
+ if settled or text == "" or on_output is None or callback_error is not None:
1448
+ return
1449
+ try:
1450
+ on_output(text, context, ShellOutputInfo(stream=cast(Any, stream)))
1451
+ except Exception as error:
1452
+ cause = to_error(error)
1453
+ callback_error = ExecutionError("callback_error", str(cause), cause)
1454
+ _kill_process(process)
1455
+
1456
+ async def pump(stream: asyncio.StreamReader, decoder: StreamDecoder, name: str) -> None:
1457
+ while True:
1458
+ chunk = await stream.read(65536)
1459
+ if not chunk:
1460
+ break
1461
+ text = decoder.decode(chunk)
1462
+ emit(text, name)
1463
+ if spill is not None:
1464
+ await spill.feed(chunk)
1465
+ emit(decoder.decode(), name)
1466
+
1467
+ stdout_task = asyncio.create_task(pump(cast(Any, process.stdout), StreamDecoder(), "stdout"))
1468
+ stderr_task = asyncio.create_task(pump(cast(Any, process.stderr), StreamDecoder(), "stderr"))
1469
+
1470
+ def on_timeout() -> None:
1471
+ nonlocal timed_out
1472
+ timed_out = True
1473
+ _kill_process(process)
1474
+
1475
+ if timeout.value is not None:
1476
+ timeout_handle = loop.call_later(timeout.value / 1000, on_timeout)
1477
+ if signal_state is not None:
1478
+ remove_abort = signal_state.add_listener(lambda: _kill_process(process))
1479
+ if signal_state.aborted:
1480
+ _kill_process(process)
1481
+
1482
+ try:
1483
+ await process.wait()
1484
+ grace = 5.0 if spill is not None else _EXIT_STDIO_GRACE_SECONDS
1485
+ try:
1486
+ await asyncio.wait_for(asyncio.gather(stdout_task, stderr_task), timeout=grace)
1487
+ except TimeoutError:
1488
+ for task in (stdout_task, stderr_task):
1489
+ task.cancel()
1490
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
1491
+ if spill is not None:
1492
+ await spill.finish()
1493
+ settled = True
1494
+ except asyncio.CancelledError:
1495
+ _kill_process(process)
1496
+ for task in (stdout_task, stderr_task):
1497
+ task.cancel()
1498
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
1499
+ if spill is not None:
1500
+ await spill.finish()
1501
+ raise
1502
+ finally:
1503
+ settled = True
1504
+ if timeout_handle is not None:
1505
+ timeout_handle.cancel()
1506
+ if remove_abort is not None:
1507
+ remove_abort()
1508
+ self._children.discard(process)
1509
+
1510
+ if callback_error is not None:
1511
+ return err(callback_error)
1512
+ interrupted: ExecutionError | None = None
1513
+ if timed_out:
1514
+ interrupted = ExecutionError("timeout", f"timeout:{opts.get('timeout')}")
1515
+ elif signal_state is not None and signal_state.aborted:
1516
+ interrupted = ExecutionError("aborted", "aborted")
1517
+ if interrupted is not None:
1518
+ if spill is not None and spill.path is not None:
1519
+ interrupted.spill_path = spill.path
1520
+ return err(interrupted)
1521
+ if spill is not None and spill.error is not None:
1522
+ return err(spill.error)
1523
+ if stream_error is not None:
1524
+ return err(stream_error)
1525
+ return_code = process.returncode
1526
+ exit_code = 1
1527
+ if return_code is not None:
1528
+ exit_code = return_code if return_code >= 0 else 128 + abs(return_code)
1529
+ result = ShellExecResult(exitCode=exit_code)
1530
+ if spill is not None and spill.path is not None:
1531
+ result["spillPath"] = spill.path
1532
+ return ok(result)
1533
+
1534
+ async def cleanup(self, context: Context) -> None:
1535
+ del context
1536
+ for process in list(self._children):
1537
+ _kill_process(process)
1538
+ self._children.clear()
1539
+
1540
+
1541
+ __all__ = [
1542
+ "PosixExecutionEnv",
1543
+ "PosixFileWatcher",
1544
+ "PosixWatchOptions",
1545
+ "resolve_path",
1546
+ "to_file_error",
1547
+ ]