pi-durable-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
"""Runner-independent ``ExecutionEnv`` conformance cases.
|
|
2
|
+
|
|
3
|
+
Translated from v1.3.0 ``testing/env-conformance``. A provider supplies a fresh, empty, writable
|
|
4
|
+
directory for every case, so the suite exercises only the public environment contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..context import (
|
|
14
|
+
BACKGROUND_CONTEXT,
|
|
15
|
+
AbortController,
|
|
16
|
+
Context,
|
|
17
|
+
with_abort_signal,
|
|
18
|
+
)
|
|
19
|
+
from ..env import (
|
|
20
|
+
ExecutionEnv,
|
|
21
|
+
FileInfo,
|
|
22
|
+
ListFilesOptions,
|
|
23
|
+
Result,
|
|
24
|
+
ShellExecOptions,
|
|
25
|
+
ShellOutputInfo,
|
|
26
|
+
WatchChange,
|
|
27
|
+
WatchTarget,
|
|
28
|
+
get_or_throw,
|
|
29
|
+
)
|
|
30
|
+
from ..truncate import utf8_byte_length
|
|
31
|
+
from .types import (
|
|
32
|
+
EnvConformanceAssertions,
|
|
33
|
+
EnvConformanceCase,
|
|
34
|
+
EnvConformanceOptions,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
context = BACKGROUND_CONTEXT
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _aborted_context() -> Context:
|
|
41
|
+
controller = AbortController()
|
|
42
|
+
controller.abort()
|
|
43
|
+
return with_abort_signal(controller.signal, BACKGROUND_CONTEXT)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _error_code(result: Result[Any, Any]) -> str | None:
|
|
47
|
+
return None if result.ok else result.error.code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _read_all(env: ExecutionEnv, path: str, max_entries: int) -> tuple[list[list[FileInfo]], bool]:
|
|
51
|
+
reader = get_or_throw(await env.open_dir_reader(path, context))
|
|
52
|
+
pages: list[list[FileInfo]] = []
|
|
53
|
+
try:
|
|
54
|
+
for _ in range(1000):
|
|
55
|
+
next_page = get_or_throw(await reader.next(max_entries, context))
|
|
56
|
+
pages.append(next_page["entries"])
|
|
57
|
+
if next_page["done"]:
|
|
58
|
+
return pages, True
|
|
59
|
+
return pages, False
|
|
60
|
+
finally:
|
|
61
|
+
await reader.close(context)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _watch_targets(*entries: dict[str, Any]) -> list[WatchTarget]:
|
|
65
|
+
return [WatchTarget(entry) for entry in entries]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _list_options(entry: dict[str, Any] | None) -> ListFilesOptions | None:
|
|
69
|
+
return None if entry is None else ListFilesOptions(entry)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _shell_options(entry: dict[str, Any]) -> ShellExecOptions:
|
|
73
|
+
return ShellExecOptions(entry)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _listed(
|
|
77
|
+
env: ExecutionEnv, path: str, options: dict[str, Any] | None = None
|
|
78
|
+
) -> tuple[list[str], bool]:
|
|
79
|
+
listing = get_or_throw(await env.list_files(path, _list_options(options), context))
|
|
80
|
+
letters = {"file": "f", "directory": "d", "symlink": "l"}
|
|
81
|
+
return [f"{letters[entry['kind']]} {entry['path']}" for entry in listing["entries"]], listing["truncated"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _covers(change: WatchChange, path: str) -> bool:
|
|
85
|
+
if "overflow" in change:
|
|
86
|
+
return True
|
|
87
|
+
if "paths" not in change:
|
|
88
|
+
return False
|
|
89
|
+
return any(
|
|
90
|
+
path == reported or path.startswith(reported + "/") or path.startswith(reported + "\\")
|
|
91
|
+
for reported in change["paths"]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _watching(
|
|
96
|
+
env: ExecutionEnv,
|
|
97
|
+
targets: Sequence[WatchTarget],
|
|
98
|
+
run: Callable[
|
|
99
|
+
[
|
|
100
|
+
list[WatchChange],
|
|
101
|
+
Callable[[str, Callable[[], Awaitable[None]]], Awaitable[None]],
|
|
102
|
+
Callable[[str], Awaitable[str]],
|
|
103
|
+
],
|
|
104
|
+
Awaitable[None],
|
|
105
|
+
],
|
|
106
|
+
) -> None:
|
|
107
|
+
changes: list[WatchChange] = []
|
|
108
|
+
watcher = get_or_throw(await env.watch(targets, changes.append, context))
|
|
109
|
+
|
|
110
|
+
async def absolute(path: str) -> str:
|
|
111
|
+
return get_or_throw(await env.absolute_path(path, context))
|
|
112
|
+
|
|
113
|
+
async def expect_change(path: str, change: Callable[[], Awaitable[None]]) -> None:
|
|
114
|
+
target = await absolute(path)
|
|
115
|
+
start = len(changes)
|
|
116
|
+
await change()
|
|
117
|
+
deadline = asyncio.get_running_loop().time() + 3
|
|
118
|
+
while not any(_covers(entry, target) for entry in changes[start:]):
|
|
119
|
+
if asyncio.get_running_loop().time() > deadline:
|
|
120
|
+
raise AssertionError(f"No change reported {target}; got {changes[start:]!r}")
|
|
121
|
+
await asyncio.sleep(0.02)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
await run(changes, expect_change, absolute)
|
|
125
|
+
finally:
|
|
126
|
+
await watcher.close(context)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _exec_collect(
|
|
130
|
+
env: ExecutionEnv,
|
|
131
|
+
command: str | Sequence[str],
|
|
132
|
+
cwd: str | None = None,
|
|
133
|
+
) -> tuple[Result[Any, Any], str, str]:
|
|
134
|
+
output = {"stdout": "", "stderr": ""}
|
|
135
|
+
|
|
136
|
+
def on_output(text: str, _context: Context, info: ShellOutputInfo) -> None:
|
|
137
|
+
output[info.stream] += text
|
|
138
|
+
|
|
139
|
+
options: dict[str, Any] = {"on_output": on_output}
|
|
140
|
+
if cwd is not None:
|
|
141
|
+
options["cwd"] = cwd
|
|
142
|
+
result = await env.exec(command, _shell_options(options), context)
|
|
143
|
+
return result, output["stdout"], output["stderr"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def create_env_conformance(options: EnvConformanceOptions) -> list[EnvConformanceCase]:
|
|
147
|
+
"""Create runner-independent cases for an :class:`ExecutionEnv`."""
|
|
148
|
+
|
|
149
|
+
assert_ = options["assertions"]
|
|
150
|
+
shell = options.get("shell", ["sh", "-c"])
|
|
151
|
+
symlinks = options.get("symlinks", True)
|
|
152
|
+
list_files = options.get("list_files", False)
|
|
153
|
+
|
|
154
|
+
def create_case(
|
|
155
|
+
name: str, test: Callable[[ExecutionEnv], Awaitable[None]], timeout_ms: int | None = None
|
|
156
|
+
) -> EnvConformanceCase:
|
|
157
|
+
def run() -> Awaitable[None]:
|
|
158
|
+
return options["with_env"](test)
|
|
159
|
+
|
|
160
|
+
return EnvConformanceCase(name, run, timeout_ms)
|
|
161
|
+
|
|
162
|
+
def _watch_case(name: str, test: Callable[[ExecutionEnv], Awaitable[None]]) -> EnvConformanceCase:
|
|
163
|
+
return create_case(name, test, timeout_ms=30_000)
|
|
164
|
+
|
|
165
|
+
cases: list[EnvConformanceCase] = [
|
|
166
|
+
create_case(
|
|
167
|
+
"binary reader reads byte ranges of the opened file",
|
|
168
|
+
lambda env: _binary_reader_ranges(env, assert_),
|
|
169
|
+
),
|
|
170
|
+
create_case(
|
|
171
|
+
"binary reader scans lines like decoding the whole file",
|
|
172
|
+
lambda env: _binary_reader_scans(env, assert_),
|
|
173
|
+
),
|
|
174
|
+
create_case(
|
|
175
|
+
"binary reader keeps reading the file it opened after a rename",
|
|
176
|
+
lambda env: _binary_reader_after_rename(env, assert_),
|
|
177
|
+
),
|
|
178
|
+
create_case(
|
|
179
|
+
"binary reader refuses directories, missing files and aborted opens",
|
|
180
|
+
lambda env: _binary_reader_refusals(env, assert_),
|
|
181
|
+
),
|
|
182
|
+
create_case(
|
|
183
|
+
"directory reader pages every entry exactly once",
|
|
184
|
+
lambda env: _directory_reader_pages(env, assert_),
|
|
185
|
+
),
|
|
186
|
+
create_case(
|
|
187
|
+
"directory reader reports the end and refuses use after close",
|
|
188
|
+
lambda env: _directory_reader_end(env, assert_),
|
|
189
|
+
),
|
|
190
|
+
create_case(
|
|
191
|
+
"directory reader refuses missing paths and files",
|
|
192
|
+
lambda env: _directory_reader_refusals(env, assert_),
|
|
193
|
+
),
|
|
194
|
+
create_case(
|
|
195
|
+
"directory reader skips entries removed during enumeration",
|
|
196
|
+
lambda env: _directory_reader_removed(env, assert_),
|
|
197
|
+
),
|
|
198
|
+
_watch_case(
|
|
199
|
+
"watch reports a missing file's creation, changes, replacement and removal",
|
|
200
|
+
lambda env: _watch_file_lifecycle(env, assert_),
|
|
201
|
+
),
|
|
202
|
+
_watch_case(
|
|
203
|
+
"watch reports a missing target whose ancestors are created",
|
|
204
|
+
lambda env: _watch_missing_ancestors(env, assert_),
|
|
205
|
+
),
|
|
206
|
+
_watch_case(
|
|
207
|
+
"watch follows directories created together with their contents",
|
|
208
|
+
lambda env: _watch_new_directory_tree(env, assert_),
|
|
209
|
+
),
|
|
210
|
+
_watch_case(
|
|
211
|
+
"watch keeps watching a path whose parent is renamed and recreated",
|
|
212
|
+
lambda env: _watch_renamed_parent(env, assert_),
|
|
213
|
+
),
|
|
214
|
+
_watch_case(
|
|
215
|
+
"watch skips excluded entries and reports a rename out of them",
|
|
216
|
+
lambda env: _watch_excluded(env, assert_),
|
|
217
|
+
),
|
|
218
|
+
_watch_case(
|
|
219
|
+
"watch keeps recursive coverage where a non-recursive target overlaps",
|
|
220
|
+
lambda env: _watch_overlapping(env, assert_),
|
|
221
|
+
),
|
|
222
|
+
_watch_case(
|
|
223
|
+
"watch follows a directory replaced at the same path",
|
|
224
|
+
lambda env: _watch_replaced_directory(env, assert_),
|
|
225
|
+
),
|
|
226
|
+
_watch_case(
|
|
227
|
+
"watch stops reporting once closed",
|
|
228
|
+
lambda env: _watch_closed(env, assert_),
|
|
229
|
+
),
|
|
230
|
+
create_case(
|
|
231
|
+
"argv exec passes arguments to the program without shell parsing",
|
|
232
|
+
lambda env: _exec_argv(env, assert_, shell),
|
|
233
|
+
),
|
|
234
|
+
create_case(
|
|
235
|
+
"exec reports the stream of every chunk in both forms",
|
|
236
|
+
lambda env: _exec_streams(env, assert_, shell),
|
|
237
|
+
),
|
|
238
|
+
create_case(
|
|
239
|
+
"argv exec honors cwd and exit codes",
|
|
240
|
+
lambda env: _exec_cwd(env, assert_, shell),
|
|
241
|
+
),
|
|
242
|
+
create_case(
|
|
243
|
+
"argv exec reports missing programs and empty argv as spawn errors",
|
|
244
|
+
lambda env: _exec_spawn_errors(env, assert_),
|
|
245
|
+
),
|
|
246
|
+
create_case(
|
|
247
|
+
"windowed exec keeps the exact tail and counts what it skips",
|
|
248
|
+
lambda env: _exec_window(env, assert_, shell),
|
|
249
|
+
),
|
|
250
|
+
create_case(
|
|
251
|
+
"argv exec distinguishes timeout from abort",
|
|
252
|
+
lambda env: _exec_timeout(env, assert_, shell),
|
|
253
|
+
),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
if list_files:
|
|
257
|
+
cases.extend(
|
|
258
|
+
[
|
|
259
|
+
create_case(
|
|
260
|
+
"listFiles lists files and directories depth first in name order",
|
|
261
|
+
lambda env: _list_files_order(env, assert_),
|
|
262
|
+
),
|
|
263
|
+
create_case(
|
|
264
|
+
"listFiles skips what git ignores in work trees, .git, and excluded names",
|
|
265
|
+
lambda env: _list_files_gitignore(env, assert_),
|
|
266
|
+
),
|
|
267
|
+
create_case(
|
|
268
|
+
"listFiles stops at its bounds and reports truncated",
|
|
269
|
+
lambda env: _list_files_bounds(env, assert_),
|
|
270
|
+
),
|
|
271
|
+
create_case(
|
|
272
|
+
"listFiles fails on missing directories, files, and aborts",
|
|
273
|
+
lambda env: _list_files_errors(env, assert_),
|
|
274
|
+
),
|
|
275
|
+
]
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if symlinks:
|
|
279
|
+
cases.append(
|
|
280
|
+
create_case(
|
|
281
|
+
"binary reader follows symlinks unless noFollow refuses the final one",
|
|
282
|
+
lambda env: _binary_reader_symlinks(env, assert_, shell),
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
if list_files:
|
|
286
|
+
cases.append(
|
|
287
|
+
create_case(
|
|
288
|
+
"listFiles lists symbolic links without following them",
|
|
289
|
+
lambda env: _list_files_symlinks(env, assert_, shell),
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
cases.append(
|
|
293
|
+
_watch_case(
|
|
294
|
+
"watch reports changes to the file a watched symbolic link points to",
|
|
295
|
+
lambda env: _watch_symlink_target(env, assert_, shell),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return cases
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _binary_reader_ranges(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
303
|
+
get_or_throw(await env.write_file("data.txt", "hello world", context))
|
|
304
|
+
reader = get_or_throw(await env.open_binary_reader("data.txt", None, context))
|
|
305
|
+
info = get_or_throw(await reader.info(context))
|
|
306
|
+
assert_.partial_deep_equal(info, {"name": "data.txt", "kind": "file", "size": 11})
|
|
307
|
+
assert_.strict_equal(get_or_throw(await reader.read(0, 5, context)).decode(), "hello")
|
|
308
|
+
assert_.strict_equal(get_or_throw(await reader.read(6, 100, context)).decode(), "world")
|
|
309
|
+
assert_.strict_equal(len(get_or_throw(await reader.read(11, 4, context))), 0)
|
|
310
|
+
assert_.strict_equal(len(get_or_throw(await reader.read(50, 1, context))), 0)
|
|
311
|
+
assert_.strict_equal(len(get_or_throw(await reader.read(3, 0, context))), 0)
|
|
312
|
+
assert_.strict_equal(_error_code(await reader.read(-1, 1, context)), "invalid")
|
|
313
|
+
assert_.strict_equal(_error_code(await reader.read(0, 1.5, context)), "invalid") # type: ignore[arg-type]
|
|
314
|
+
assert_.strict_equal(_error_code(await reader.read(0, 1, _aborted_context())), "aborted")
|
|
315
|
+
await reader.close(context)
|
|
316
|
+
await reader.close(context)
|
|
317
|
+
assert_.strict_equal(_error_code(await reader.read(0, 1, context)), "invalid")
|
|
318
|
+
assert_.strict_equal(_error_code(await reader.info(context)), "invalid")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def _binary_reader_scans(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
322
|
+
data = bytes(
|
|
323
|
+
[
|
|
324
|
+
0xEF,
|
|
325
|
+
0xBB,
|
|
326
|
+
0xBF,
|
|
327
|
+
0x61,
|
|
328
|
+
0x0A,
|
|
329
|
+
0xE2,
|
|
330
|
+
0x82,
|
|
331
|
+
0x0A,
|
|
332
|
+
0x0A,
|
|
333
|
+
0xEF,
|
|
334
|
+
0xBB,
|
|
335
|
+
0xBF,
|
|
336
|
+
0x62,
|
|
337
|
+
0x0A,
|
|
338
|
+
0xC3,
|
|
339
|
+
0xA9,
|
|
340
|
+
]
|
|
341
|
+
)
|
|
342
|
+
get_or_throw(await env.write_file("lines.txt", data, context))
|
|
343
|
+
decoded = data.decode("utf-8", "replace")
|
|
344
|
+
lines = (decoded[1:] if decoded.startswith("\ufeff") else decoded).split("\n")
|
|
345
|
+
reader = get_or_throw(await env.open_binary_reader("lines.txt", None, context))
|
|
346
|
+
try:
|
|
347
|
+
for start_line, end_line in [
|
|
348
|
+
(0, None),
|
|
349
|
+
(0, 1),
|
|
350
|
+
(1, 3),
|
|
351
|
+
(2, 3),
|
|
352
|
+
(3, None),
|
|
353
|
+
(4, 9),
|
|
354
|
+
]:
|
|
355
|
+
selection: dict[str, Any] = {"start_line": start_line}
|
|
356
|
+
if end_line is not None:
|
|
357
|
+
selection["end_line"] = end_line
|
|
358
|
+
scan = get_or_throw(await reader.scan_lines(selection, context))
|
|
359
|
+
selected = lines[start_line:end_line]
|
|
360
|
+
|
|
361
|
+
def decode_range(start: int, end: int, ignore_bom: bool) -> str:
|
|
362
|
+
payload = data[start:end]
|
|
363
|
+
if not ignore_bom and payload.startswith(b"\xef\xbb\xbf"):
|
|
364
|
+
payload = payload[3:]
|
|
365
|
+
return payload.decode("utf-8", "replace")
|
|
366
|
+
|
|
367
|
+
assert_.strict_equal(scan["newlines"], len(lines) - 1)
|
|
368
|
+
assert_.strict_equal(
|
|
369
|
+
decode_range(scan["start"], scan["end"], scan["start"] > 0),
|
|
370
|
+
"\n".join(selected),
|
|
371
|
+
)
|
|
372
|
+
assert_.strict_equal(scan["selectedBytes"], utf8_byte_length("\n".join(selected)))
|
|
373
|
+
assert_.strict_equal(
|
|
374
|
+
decode_range(scan["start"], scan["firstLineEnd"], scan["start"] > 0), lines[start_line]
|
|
375
|
+
)
|
|
376
|
+
assert_.strict_equal(scan["firstLineBytes"], utf8_byte_length(lines[start_line]))
|
|
377
|
+
assert_.partial_deep_equal(
|
|
378
|
+
get_or_throw(await reader.scan_lines({"start_line": 9}, context)),
|
|
379
|
+
{"start": len(data), "end": len(data), "selectedBytes": 0},
|
|
380
|
+
)
|
|
381
|
+
assert_.strict_equal(
|
|
382
|
+
_error_code(await reader.scan_lines({"start_line": 2, "end_line": 2}, context)),
|
|
383
|
+
"invalid",
|
|
384
|
+
)
|
|
385
|
+
finally:
|
|
386
|
+
await reader.close(context)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def _binary_reader_after_rename(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
390
|
+
get_or_throw(await env.write_file("a.txt", "one", context))
|
|
391
|
+
reader = get_or_throw(await env.open_binary_reader("a.txt", None, context))
|
|
392
|
+
try:
|
|
393
|
+
get_or_throw(await env.rename_file("a.txt", "b.txt", context))
|
|
394
|
+
get_or_throw(await env.write_file("a.txt", "two", context))
|
|
395
|
+
assert_.strict_equal(get_or_throw(await reader.read(0, 10, context)).decode(), "one")
|
|
396
|
+
finally:
|
|
397
|
+
await reader.close(context)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
async def _binary_reader_refusals(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
401
|
+
get_or_throw(await env.create_dir("dir", None, context))
|
|
402
|
+
get_or_throw(await env.write_file("file.txt", "x", context))
|
|
403
|
+
assert_.strict_equal(_error_code(await env.open_binary_reader("dir", None, context)), "is_directory")
|
|
404
|
+
assert_.strict_equal(_error_code(await env.open_binary_reader("missing.txt", None, context)), "not_found")
|
|
405
|
+
assert_.strict_equal(
|
|
406
|
+
_error_code(await env.open_binary_reader("file.txt", None, _aborted_context())),
|
|
407
|
+
"aborted",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def _directory_reader_pages(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
412
|
+
names = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]
|
|
413
|
+
for name in names:
|
|
414
|
+
get_or_throw(await env.write_file(name, name, context))
|
|
415
|
+
get_or_throw(await env.create_dir("sub", None, context))
|
|
416
|
+
pages, done = await _read_all(env, ".", 2)
|
|
417
|
+
assert_.ok(done, "directory reader reached the end")
|
|
418
|
+
for page in pages:
|
|
419
|
+
assert_.ok(len(page) <= 2, "page within maxEntries")
|
|
420
|
+
entries = [entry for page in pages for entry in page]
|
|
421
|
+
assert_.deep_equal(sorted(entry["name"] for entry in entries), sorted([*names, "sub"]))
|
|
422
|
+
sub = next(entry for entry in entries if entry["name"] == "sub")
|
|
423
|
+
assert_.strict_equal(sub["kind"], "directory")
|
|
424
|
+
assert_.partial_deep_equal(
|
|
425
|
+
next(entry for entry in entries if entry["name"] == "a.txt"),
|
|
426
|
+
{"kind": "file", "size": 5},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def _directory_reader_end(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
431
|
+
get_or_throw(await env.create_dir("empty", None, context))
|
|
432
|
+
reader = get_or_throw(await env.open_dir_reader("empty", context))
|
|
433
|
+
assert_.deep_equal(get_or_throw(await reader.next(10, context)), {"entries": [], "done": True})
|
|
434
|
+
assert_.deep_equal(get_or_throw(await reader.next(10, context)), {"entries": [], "done": True})
|
|
435
|
+
assert_.strict_equal(_error_code(await reader.next(0, context)), "invalid")
|
|
436
|
+
assert_.strict_equal(_error_code(await reader.next(1, _aborted_context())), "aborted")
|
|
437
|
+
await reader.close(context)
|
|
438
|
+
await reader.close(context)
|
|
439
|
+
assert_.strict_equal(_error_code(await reader.next(1, context)), "invalid")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def _directory_reader_refusals(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
443
|
+
get_or_throw(await env.write_file("file.txt", "x", context))
|
|
444
|
+
assert_.strict_equal(_error_code(await env.open_dir_reader("missing", context)), "not_found")
|
|
445
|
+
file_result = await env.open_dir_reader("file.txt", context)
|
|
446
|
+
assert_.strict_equal(_error_code(file_result), "not_directory")
|
|
447
|
+
assert_.strict_equal(_error_code(await env.open_dir_reader(".", _aborted_context())), "aborted")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def _directory_reader_removed(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
451
|
+
get_or_throw(await env.create_dir("dir", None, context))
|
|
452
|
+
for name in ["x", "y", "z"]:
|
|
453
|
+
get_or_throw(await env.write_file(f"dir/{name}", name, context))
|
|
454
|
+
reader = get_or_throw(await env.open_dir_reader("dir", context))
|
|
455
|
+
try:
|
|
456
|
+
for name in ["x", "y", "z"]:
|
|
457
|
+
get_or_throw(await env.remove(f"dir/{name}", None, context))
|
|
458
|
+
entries: list[FileInfo] = []
|
|
459
|
+
for _ in range(10):
|
|
460
|
+
next_page = get_or_throw(await reader.next(10, context))
|
|
461
|
+
entries.extend(next_page["entries"])
|
|
462
|
+
if next_page["done"]:
|
|
463
|
+
break
|
|
464
|
+
assert_.deep_equal(entries, [])
|
|
465
|
+
finally:
|
|
466
|
+
await reader.close(context)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def _watch_file_lifecycle(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
470
|
+
del assert_
|
|
471
|
+
|
|
472
|
+
async def run(changes, expect_change, absolute):
|
|
473
|
+
del changes, absolute
|
|
474
|
+
await expect_change("AGENTS.md", lambda: env.write_file("AGENTS.md", "one", context))
|
|
475
|
+
await expect_change("AGENTS.md", lambda: env.write_file("AGENTS.md", "two!", context))
|
|
476
|
+
await expect_change(
|
|
477
|
+
"AGENTS.md",
|
|
478
|
+
lambda: _replace_file(env),
|
|
479
|
+
)
|
|
480
|
+
await expect_change("AGENTS.md", lambda: env.write_file("AGENTS.md", "four", context))
|
|
481
|
+
await expect_change("AGENTS.md", lambda: env.remove("AGENTS.md", None, context))
|
|
482
|
+
|
|
483
|
+
await _watching(env, _watch_targets({"path": "AGENTS.md"}), run)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def _replace_file(env: ExecutionEnv) -> None:
|
|
487
|
+
get_or_throw(await env.write_file("AGENTS.md.tmp", "three", context))
|
|
488
|
+
get_or_throw(await env.rename_file("AGENTS.md.tmp", "AGENTS.md", context))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
async def _watch_missing_ancestors(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
492
|
+
del assert_
|
|
493
|
+
|
|
494
|
+
async def run(changes, expect_change, absolute):
|
|
495
|
+
del changes, absolute
|
|
496
|
+
await expect_change("a/b/c/AGENTS.md", lambda: env.write_file("a/b/c/AGENTS.md", "x", context))
|
|
497
|
+
|
|
498
|
+
await _watching(env, _watch_targets({"path": "a/b/c/AGENTS.md"}), run)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
async def _watch_new_directory_tree(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
502
|
+
del assert_
|
|
503
|
+
get_or_throw(await env.create_dir("skills", None, context))
|
|
504
|
+
|
|
505
|
+
async def run(changes, expect_change, absolute):
|
|
506
|
+
del changes, absolute
|
|
507
|
+
await expect_change(
|
|
508
|
+
"skills/a/b/SKILL.md",
|
|
509
|
+
lambda: env.write_file("skills/a/b/SKILL.md", "one", context),
|
|
510
|
+
)
|
|
511
|
+
await expect_change(
|
|
512
|
+
"skills/a/b/SKILL.md",
|
|
513
|
+
lambda: env.write_file("skills/a/b/SKILL.md", "two!", context),
|
|
514
|
+
)
|
|
515
|
+
await expect_change(
|
|
516
|
+
"skills/a/b/c/SKILL.md",
|
|
517
|
+
lambda: env.write_file("skills/a/b/c/SKILL.md", "deeper", context),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
await _watching(env, _watch_targets({"path": "skills", "recursive": True}), run)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def _watch_renamed_parent(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
524
|
+
del assert_
|
|
525
|
+
get_or_throw(await env.write_file("proj/.pi/skills/x.md", "x", context))
|
|
526
|
+
|
|
527
|
+
async def run(changes, expect_change, absolute):
|
|
528
|
+
del changes, absolute
|
|
529
|
+
await expect_change("proj/.pi/skills", lambda: env.rename_file("proj/.pi", "proj/old", context))
|
|
530
|
+
await expect_change(
|
|
531
|
+
"proj/.pi/skills/y.md",
|
|
532
|
+
lambda: env.write_file("proj/.pi/skills/y.md", "y", context),
|
|
533
|
+
)
|
|
534
|
+
await expect_change(
|
|
535
|
+
"proj/.pi/skills/y.md",
|
|
536
|
+
lambda: env.write_file("proj/.pi/skills/y.md", "yy", context),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
await _watching(env, _watch_targets({"path": "proj/.pi/skills", "recursive": True}), run)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
async def _watch_excluded(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
543
|
+
get_or_throw(await env.create_dir("skills", None, context))
|
|
544
|
+
|
|
545
|
+
async def run(changes, expect_change, absolute):
|
|
546
|
+
get_or_throw(await env.write_file("skills/node_modules/dep/SKILL.md", "dep", context))
|
|
547
|
+
get_or_throw(await env.write_file("skills/.SKILL.md.tmp", "draft", context))
|
|
548
|
+
await expect_change(
|
|
549
|
+
"skills/SKILL.md",
|
|
550
|
+
lambda: env.rename_file("skills/.SKILL.md.tmp", "skills/SKILL.md", context),
|
|
551
|
+
)
|
|
552
|
+
hidden = [await absolute("skills/node_modules"), await absolute("skills/.SKILL.md.tmp")]
|
|
553
|
+
for change in changes:
|
|
554
|
+
if "paths" not in change:
|
|
555
|
+
continue
|
|
556
|
+
for path in change["paths"]:
|
|
557
|
+
assert_.ok(
|
|
558
|
+
not any(path == excluded or path.startswith(excluded) for excluded in hidden),
|
|
559
|
+
f"excluded {path}",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
await _watching(
|
|
563
|
+
env,
|
|
564
|
+
_watch_targets(
|
|
565
|
+
{
|
|
566
|
+
"path": "skills",
|
|
567
|
+
"recursive": True,
|
|
568
|
+
"exclude": {"hidden": True, "names": ["node_modules"]},
|
|
569
|
+
}
|
|
570
|
+
),
|
|
571
|
+
run,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
async def _watch_overlapping(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
576
|
+
del assert_
|
|
577
|
+
get_or_throw(await env.write_file("skills/a/one.md", "one", context))
|
|
578
|
+
|
|
579
|
+
async def run(changes, expect_change, absolute):
|
|
580
|
+
del changes, absolute
|
|
581
|
+
await expect_change("skills/a/two.md", lambda: env.write_file("skills/a/two.md", "two", context))
|
|
582
|
+
|
|
583
|
+
await _watching(env, _watch_targets({"path": "skills"}, {"path": "skills", "recursive": True}), run)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def _watch_replaced_directory(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
587
|
+
del assert_
|
|
588
|
+
get_or_throw(await env.write_file("skills/a/x.md", "x", context))
|
|
589
|
+
|
|
590
|
+
async def run(changes, expect_change, absolute):
|
|
591
|
+
del changes, absolute
|
|
592
|
+
|
|
593
|
+
async def replace() -> None:
|
|
594
|
+
get_or_throw(await env.rename_file("skills/a", "skills-old", context))
|
|
595
|
+
get_or_throw(await env.create_dir("skills/a", None, context))
|
|
596
|
+
|
|
597
|
+
await expect_change("skills/a", replace)
|
|
598
|
+
await expect_change("skills/a/y.md", lambda: env.write_file("skills/a/y.md", "y", context))
|
|
599
|
+
await expect_change("skills/a/y.md", lambda: env.write_file("skills/a/y.md", "yy", context))
|
|
600
|
+
|
|
601
|
+
await _watching(env, _watch_targets({"path": "skills", "recursive": True}), run)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
async def _watch_closed(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
605
|
+
changes: list[WatchChange] = []
|
|
606
|
+
watcher = get_or_throw(await env.watch(_watch_targets({"path": "file.txt"}), changes.append, context))
|
|
607
|
+
assert_.ok(watcher.mode in ("native", "polling"), "watcher reports its mode")
|
|
608
|
+
await watcher.close(context)
|
|
609
|
+
await watcher.close(context)
|
|
610
|
+
get_or_throw(await env.write_file("file.txt", "x", context))
|
|
611
|
+
await asyncio.sleep(0.3)
|
|
612
|
+
assert_.deep_equal(changes, [])
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
async def _exec_argv(env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]) -> None:
|
|
616
|
+
hostile = "it's $(touch pwned) `touch pwned` *; touch pwned"
|
|
617
|
+
result, stdout, _ = await _exec_collect(
|
|
618
|
+
env,
|
|
619
|
+
[*shell, 'printf "%s|%s" "$1" "$2"', "argv0", hostile, "a b"],
|
|
620
|
+
)
|
|
621
|
+
assert_.strict_equal(get_or_throw(result)["exitCode"], 0)
|
|
622
|
+
assert_.strict_equal(stdout, f"{hostile}|a b")
|
|
623
|
+
assert_.strict_equal(get_or_throw(await env.exists("pwned", context)), False)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def _exec_streams(env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]) -> None:
|
|
627
|
+
script = "printf out; printf err >&2; printf more"
|
|
628
|
+
argv_result, argv_stdout, argv_stderr = await _exec_collect(env, [*shell, script])
|
|
629
|
+
assert_.strict_equal(get_or_throw(argv_result)["exitCode"], 0)
|
|
630
|
+
assert_.strict_equal(argv_stdout, "outmore")
|
|
631
|
+
assert_.strict_equal(argv_stderr, "err")
|
|
632
|
+
string_result, string_stdout, string_stderr = await _exec_collect(env, script)
|
|
633
|
+
assert_.strict_equal(get_or_throw(string_result)["exitCode"], 0)
|
|
634
|
+
assert_.strict_equal(string_stdout, "outmore")
|
|
635
|
+
assert_.strict_equal(string_stderr, "err")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
async def _exec_cwd(env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]) -> None:
|
|
639
|
+
get_or_throw(await env.create_dir("sub", None, context))
|
|
640
|
+
made, _, _ = await _exec_collect(env, [*shell, "printf x > made.txt; exit 3"], "sub")
|
|
641
|
+
assert_.strict_equal(get_or_throw(made)["exitCode"], 3)
|
|
642
|
+
assert_.strict_equal(get_or_throw(await env.read_text_file("sub/made.txt", context)), "x")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def _exec_spawn_errors(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
646
|
+
assert_.strict_equal(
|
|
647
|
+
_error_code(await env.exec(["pi-durable-conformance-missing-program"], None, context)),
|
|
648
|
+
"spawn_error",
|
|
649
|
+
)
|
|
650
|
+
assert_.strict_equal(_error_code(await env.exec([], None, context)), "spawn_error")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
async def _exec_window(env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]) -> None:
|
|
654
|
+
line_count = 2000
|
|
655
|
+
window = {"max_bytes": 200, "max_lines": 5, "min_interval_ms": 0, "bytes_per_second": 1_000_000_000}
|
|
656
|
+
total_bytes = 0
|
|
657
|
+
total_newlines = 0
|
|
658
|
+
tail = ""
|
|
659
|
+
|
|
660
|
+
def on_output(text: str, _context: Context, info: ShellOutputInfo) -> None:
|
|
661
|
+
nonlocal total_bytes, total_newlines, tail
|
|
662
|
+
if info.skipped is not None:
|
|
663
|
+
total_bytes += info.skipped.bytes
|
|
664
|
+
total_newlines += info.skipped.newlines
|
|
665
|
+
after = len(text.encode())
|
|
666
|
+
after_newlines = text.count("\n")
|
|
667
|
+
assert_.ok(
|
|
668
|
+
after > window["max_bytes"] or after_newlines > window["max_lines"],
|
|
669
|
+
"a skip is followed by more than the window",
|
|
670
|
+
)
|
|
671
|
+
tail = ""
|
|
672
|
+
total_bytes += len(text.encode())
|
|
673
|
+
total_newlines += text.count("\n")
|
|
674
|
+
tail += text
|
|
675
|
+
|
|
676
|
+
result = await env.exec(
|
|
677
|
+
[*shell, f"i=0; while [ $i -lt {line_count} ]; do echo line-$i; i=$((i+1)); done"],
|
|
678
|
+
_shell_options({"window": window, "on_output": on_output}),
|
|
679
|
+
context,
|
|
680
|
+
)
|
|
681
|
+
assert_.strict_equal(get_or_throw(result)["exitCode"], 0)
|
|
682
|
+
expected = [f"line-{index}\n" for index in range(line_count)]
|
|
683
|
+
assert_.strict_equal(total_bytes, len("".join(expected)))
|
|
684
|
+
assert_.strict_equal(total_newlines, line_count)
|
|
685
|
+
assert_.ok(
|
|
686
|
+
tail.endswith("".join(expected[-window["max_lines"] :])),
|
|
687
|
+
"the delivered output ends with the tail",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
async def _exec_timeout(env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]) -> None:
|
|
692
|
+
timed_out = await env.exec([*shell, "sleep 2"], _shell_options({"timeout": 0.1}), context)
|
|
693
|
+
assert_.strict_equal(_error_code(timed_out), "timeout")
|
|
694
|
+
controller = AbortController()
|
|
695
|
+
running = env.exec([*shell, "sleep 2"], None, with_abort_signal(controller.signal, context))
|
|
696
|
+
await asyncio.sleep(0.1)
|
|
697
|
+
controller.abort()
|
|
698
|
+
assert_.strict_equal(_error_code(await running), "aborted")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
async def _list_files_order(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
702
|
+
for path in ["b.txt", "a/z.txt", "a/b/c.txt", ".hidden/x", "A.txt"]:
|
|
703
|
+
get_or_throw(await env.write_file(path, "", context))
|
|
704
|
+
get_or_throw(await env.create_dir("empty", None, context))
|
|
705
|
+
entries, truncated = await _listed(env, ".")
|
|
706
|
+
assert_.deep_equal(
|
|
707
|
+
(entries, truncated),
|
|
708
|
+
(
|
|
709
|
+
[
|
|
710
|
+
"d .hidden",
|
|
711
|
+
"f .hidden/x",
|
|
712
|
+
"f A.txt",
|
|
713
|
+
"d a",
|
|
714
|
+
"d a/b",
|
|
715
|
+
"f a/b/c.txt",
|
|
716
|
+
"f a/z.txt",
|
|
717
|
+
"f b.txt",
|
|
718
|
+
"d empty",
|
|
719
|
+
],
|
|
720
|
+
False,
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
assert_.deep_equal(
|
|
724
|
+
(await _listed(env, ".", {"hidden": False}))[0],
|
|
725
|
+
["f A.txt", "d a", "d a/b", "f a/b/c.txt", "f a/z.txt", "f b.txt", "d empty"],
|
|
726
|
+
)
|
|
727
|
+
expected = (["d b", "f b/c.txt", "f z.txt"], False)
|
|
728
|
+
assert_.deep_equal(await _listed(env, "a"), expected)
|
|
729
|
+
assert_.deep_equal(await _listed(env, get_or_throw(await env.absolute_path("a", context))), expected)
|
|
730
|
+
assert_.deep_equal(await _listed(env, "empty"), ([], False))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
async def _list_files_gitignore(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
734
|
+
for path in ["keep.txt", "out/x.js", "sub/keep.txt", "sub/drop.log", "node_modules/m/i.js"]:
|
|
735
|
+
get_or_throw(await env.write_file(path, "", context))
|
|
736
|
+
get_or_throw(await env.write_file(".gitignore", "out/\n*.log\n", context))
|
|
737
|
+
get_or_throw(await env.write_file("sub/.gitignore", "!keep.txt\n", context))
|
|
738
|
+
assert_.ok("f out/x.js" in (await _listed(env, "."))[0])
|
|
739
|
+
get_or_throw(await env.write_file(".git/HEAD", "ref: refs/heads/main\n", context))
|
|
740
|
+
get_or_throw(await env.write_file(".git/info/exclude", "keep.txt\n", context))
|
|
741
|
+
assert_.deep_equal(
|
|
742
|
+
(await _listed(env, ".", {"exclude": ["node_modules"]}))[0],
|
|
743
|
+
["f .gitignore", "d sub", "f sub/.gitignore", "f sub/keep.txt"],
|
|
744
|
+
)
|
|
745
|
+
assert_.deep_equal((await _listed(env, "sub"))[0], ["f .gitignore", "f keep.txt"])
|
|
746
|
+
all_entries = (await _listed(env, ".", {"gitignore": False}))[0]
|
|
747
|
+
assert_.ok(
|
|
748
|
+
"f out/x.js" in all_entries and "f node_modules/m/i.js" in all_entries and "f keep.txt" in all_entries
|
|
749
|
+
)
|
|
750
|
+
assert_.ok(not any(".git/" in entry or entry == "d .git" for entry in all_entries))
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
async def _list_files_bounds(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
754
|
+
for path in ["a", "b", "c"]:
|
|
755
|
+
get_or_throw(await env.write_file(path, "", context))
|
|
756
|
+
assert_.deep_equal(await _listed(env, ".", {"max_entries": 2}), (["f a", "f b"], True))
|
|
757
|
+
assert_.deep_equal(await _listed(env, ".", {"max_entries": 3}), (["f a", "f b", "f c"], False))
|
|
758
|
+
assert_.deep_equal(await _listed(env, ".", {"max_bytes": 6}), (["f a", "f b"], True))
|
|
759
|
+
assert_.deep_equal(await _listed(env, ".", {"max_entries": 0}), ([], True))
|
|
760
|
+
assert_.strict_equal(
|
|
761
|
+
_error_code(await env.list_files(".", _list_options({"max_entries": -1}), context)),
|
|
762
|
+
"invalid",
|
|
763
|
+
)
|
|
764
|
+
assert_.strict_equal(
|
|
765
|
+
_error_code(await env.list_files(".", _list_options({"timeout_ms": 1.5}), context)),
|
|
766
|
+
"invalid",
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
async def _list_files_errors(env: ExecutionEnv, assert_: EnvConformanceAssertions) -> None:
|
|
771
|
+
get_or_throw(await env.write_file("file.txt", "", context))
|
|
772
|
+
assert_.strict_equal(_error_code(await env.list_files("missing", None, context)), "not_found")
|
|
773
|
+
assert_.strict_equal(_error_code(await env.list_files("file.txt", None, context)), "not_directory")
|
|
774
|
+
assert_.strict_equal(_error_code(await env.list_files(".", None, _aborted_context())), "aborted")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
async def _binary_reader_symlinks(
|
|
778
|
+
env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]
|
|
779
|
+
) -> None:
|
|
780
|
+
get_or_throw(await env.write_file("target.txt", "target", context))
|
|
781
|
+
get_or_throw(await env.create_dir("sub", None, context))
|
|
782
|
+
get_or_throw(await env.write_file("sub/inner.txt", "inner", context))
|
|
783
|
+
linked = await env.exec([*shell, "ln -s target.txt link.txt && ln -s sub dirlink"], None, context)
|
|
784
|
+
assert_.strict_equal(get_or_throw(linked)["exitCode"], 0)
|
|
785
|
+
|
|
786
|
+
followed = get_or_throw(await env.open_binary_reader("link.txt", None, context))
|
|
787
|
+
assert_.strict_equal(get_or_throw(await followed.read(0, 10, context)).decode(), "target")
|
|
788
|
+
await followed.close(context)
|
|
789
|
+
assert_.strict_equal(
|
|
790
|
+
_error_code(await env.open_binary_reader("link.txt", {"no_follow": True}, context)),
|
|
791
|
+
"invalid",
|
|
792
|
+
)
|
|
793
|
+
inner = get_or_throw(await env.open_binary_reader("dirlink/inner.txt", {"no_follow": True}, context))
|
|
794
|
+
assert_.strict_equal(get_or_throw(await inner.read(0, 10, context)).decode(), "inner")
|
|
795
|
+
await inner.close(context)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
async def _list_files_symlinks(
|
|
799
|
+
env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]
|
|
800
|
+
) -> None:
|
|
801
|
+
get_or_throw(await env.write_file("dir/inner.txt", "", context))
|
|
802
|
+
linked = await env.exec(
|
|
803
|
+
[*shell, "ln -s dir link && ln -s missing dangling && ln -s .. dir/loop"], None, context
|
|
804
|
+
)
|
|
805
|
+
assert_.strict_equal(get_or_throw(linked)["exitCode"], 0)
|
|
806
|
+
assert_.deep_equal(
|
|
807
|
+
(await _listed(env, "."))[0],
|
|
808
|
+
["l dangling", "d dir", "f dir/inner.txt", "l dir/loop", "l link"],
|
|
809
|
+
)
|
|
810
|
+
assert_.deep_equal((await _listed(env, "link"))[0], ["f inner.txt", "l loop"])
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def _watch_symlink_target(
|
|
814
|
+
env: ExecutionEnv, assert_: EnvConformanceAssertions, shell: Sequence[str]
|
|
815
|
+
) -> None:
|
|
816
|
+
get_or_throw(await env.write_file("data/real.md", "one", context))
|
|
817
|
+
get_or_throw(await env.create_dir("config", None, context))
|
|
818
|
+
linked = await env.exec([*shell, "ln -s ../data/real.md config/AGENTS.md"], None, context)
|
|
819
|
+
assert_.strict_equal(get_or_throw(linked)["exitCode"], 0)
|
|
820
|
+
|
|
821
|
+
async def run(changes, expect_change, absolute):
|
|
822
|
+
del changes, absolute
|
|
823
|
+
await expect_change("config/AGENTS.md", lambda: env.write_file("data/real.md", "two!", context))
|
|
824
|
+
|
|
825
|
+
await _watching(env, _watch_targets({"path": "config/AGENTS.md"}), run)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
__all__ = ["create_env_conformance"]
|