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
pi_durable/env/posix.py
ADDED
|
@@ -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
|
+
]
|