weakincentives 0.2.0__py3-none-any.whl → 0.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.

Potentially problematic release.


This version of weakincentives might be problematic. Click here for more details.

Files changed (36) hide show
  1. weakincentives/__init__.py +26 -2
  2. weakincentives/adapters/__init__.py +6 -5
  3. weakincentives/adapters/core.py +7 -17
  4. weakincentives/adapters/litellm.py +594 -0
  5. weakincentives/adapters/openai.py +286 -57
  6. weakincentives/events.py +103 -0
  7. weakincentives/examples/__init__.py +67 -0
  8. weakincentives/examples/code_review_prompt.py +118 -0
  9. weakincentives/examples/code_review_session.py +171 -0
  10. weakincentives/examples/code_review_tools.py +376 -0
  11. weakincentives/{prompts → prompt}/__init__.py +6 -8
  12. weakincentives/{prompts → prompt}/_types.py +1 -1
  13. weakincentives/{prompts/text.py → prompt/markdown.py} +19 -9
  14. weakincentives/{prompts → prompt}/prompt.py +216 -66
  15. weakincentives/{prompts → prompt}/response_format.py +9 -6
  16. weakincentives/{prompts → prompt}/section.py +25 -4
  17. weakincentives/{prompts/structured.py → prompt/structured_output.py} +16 -5
  18. weakincentives/{prompts → prompt}/tool.py +6 -6
  19. weakincentives/prompt/versioning.py +144 -0
  20. weakincentives/serde/__init__.py +0 -14
  21. weakincentives/serde/dataclass_serde.py +3 -17
  22. weakincentives/session/__init__.py +31 -0
  23. weakincentives/session/reducers.py +60 -0
  24. weakincentives/session/selectors.py +45 -0
  25. weakincentives/session/session.py +168 -0
  26. weakincentives/tools/__init__.py +69 -0
  27. weakincentives/tools/errors.py +22 -0
  28. weakincentives/tools/planning.py +538 -0
  29. weakincentives/tools/vfs.py +590 -0
  30. weakincentives-0.3.0.dist-info/METADATA +231 -0
  31. weakincentives-0.3.0.dist-info/RECORD +35 -0
  32. weakincentives-0.2.0.dist-info/METADATA +0 -173
  33. weakincentives-0.2.0.dist-info/RECORD +0 -20
  34. /weakincentives/{prompts → prompt}/errors.py +0 -0
  35. {weakincentives-0.2.0.dist-info → weakincentives-0.3.0.dist-info}/WHEEL +0 -0
  36. {weakincentives-0.2.0.dist-info → weakincentives-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,590 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Virtual filesystem tool suite."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import os
19
+ from collections.abc import Callable, Iterable, Sequence
20
+ from dataclasses import dataclass, field
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Final, Literal, cast
24
+
25
+ from ..prompt import SupportsDataclass
26
+ from ..prompt.markdown import MarkdownSection
27
+ from ..prompt.tool import Tool, ToolResult
28
+ from ..session import Session, replace_latest, select_latest
29
+ from ..session.session import DataEvent
30
+ from .errors import ToolValidationError
31
+
32
+ FileEncoding = Literal["utf-8"]
33
+ WriteMode = Literal["create", "overwrite", "append"]
34
+
35
+ _ASCII: Final[str] = "ascii"
36
+ _DEFAULT_ENCODING: Final[FileEncoding] = "utf-8"
37
+ _MAX_WRITE_LENGTH: Final[int] = 48_000
38
+ _MAX_PATH_DEPTH: Final[int] = 16
39
+ _MAX_SEGMENT_LENGTH: Final[int] = 80
40
+ _VFS_SECTION_TEMPLATE: Final[str] = (
41
+ "The virtual filesystem starts empty unless host mounts are configured."
42
+ " Use it to stage edits before applying them to the host workspace.\n"
43
+ "1. Use `vfs_list_directory` to inspect directories before reading or writing"
44
+ " specific files; keep listings focused to reduce output.\n"
45
+ "2. Fetch file contents with `vfs_read_file` and work from the returned version"
46
+ " to avoid conflicts.\n"
47
+ "3. Create or update files with `vfs_write_file`; supply ASCII content up to"
48
+ " 48k characters and prefer overwriting full files unless streaming append"
49
+ " updates.\n"
50
+ "4. Remove obsolete files or directories with `vfs_delete_entry` to keep the"
51
+ " snapshot tidy.\n"
52
+ "5. Host mounts are session-initialization only; agents cannot mount additional"
53
+ " directories later.\n"
54
+ "6. Avoid mirroring large repositories or binary assets—only ASCII text is"
55
+ " accepted and host mounts remain constrained by their configuration."
56
+ )
57
+
58
+
59
+ @dataclass(slots=True, frozen=True)
60
+ class VfsPath:
61
+ """Relative POSIX-style path representation."""
62
+
63
+ segments: tuple[str, ...]
64
+
65
+
66
+ @dataclass(slots=True, frozen=True)
67
+ class VfsFile:
68
+ path: VfsPath
69
+ content: str
70
+ encoding: FileEncoding
71
+ size_bytes: int
72
+ version: int
73
+ created_at: datetime
74
+ updated_at: datetime
75
+
76
+
77
+ @dataclass(slots=True, frozen=True)
78
+ class VirtualFileSystem:
79
+ files: tuple[VfsFile, ...] = field(default_factory=tuple)
80
+
81
+
82
+ @dataclass(slots=True, frozen=True)
83
+ class ListDirectory:
84
+ path: VfsPath | None = None
85
+
86
+
87
+ @dataclass(slots=True, frozen=True)
88
+ class ListDirectoryResult:
89
+ path: VfsPath
90
+ directories: tuple[str, ...]
91
+ files: tuple[str, ...]
92
+
93
+
94
+ @dataclass(slots=True, frozen=True)
95
+ class ReadFile:
96
+ path: VfsPath
97
+
98
+
99
+ @dataclass(slots=True, frozen=True)
100
+ class WriteFile:
101
+ path: VfsPath
102
+ content: str
103
+ mode: WriteMode = "create"
104
+ encoding: FileEncoding = _DEFAULT_ENCODING
105
+
106
+
107
+ @dataclass(slots=True, frozen=True)
108
+ class DeleteEntry:
109
+ path: VfsPath
110
+
111
+
112
+ @dataclass(slots=True, frozen=True)
113
+ class HostMount:
114
+ host_path: str
115
+ mount_path: VfsPath | None = None
116
+ include_glob: tuple[str, ...] = field(default_factory=tuple)
117
+ exclude_glob: tuple[str, ...] = field(default_factory=tuple)
118
+ max_bytes: int | None = None
119
+ follow_symlinks: bool = False
120
+
121
+
122
+ @dataclass(slots=True, frozen=True)
123
+ class _VfsSectionParams:
124
+ """Placeholder params container for the VFS tools section."""
125
+
126
+ pass
127
+
128
+
129
+ class VfsToolsSection(MarkdownSection[_VfsSectionParams]):
130
+ """Prompt section exposing the virtual filesystem tool suite."""
131
+
132
+ def __init__(
133
+ self,
134
+ *,
135
+ session: Session,
136
+ mounts: Sequence[HostMount] = (),
137
+ allowed_host_roots: Sequence[os.PathLike[str] | str] = (),
138
+ ) -> None:
139
+ self._session = session
140
+ allowed_roots = tuple(_normalize_root(path) for path in allowed_host_roots)
141
+ session.register_reducer(VirtualFileSystem, replace_latest)
142
+ mount_snapshot = _materialize_mounts(mounts, allowed_roots)
143
+ session.register_reducer(
144
+ WriteFile,
145
+ _make_write_reducer(mount_snapshot),
146
+ slice_type=VirtualFileSystem,
147
+ )
148
+ session.register_reducer(
149
+ DeleteEntry,
150
+ _make_delete_reducer(mount_snapshot),
151
+ slice_type=VirtualFileSystem,
152
+ )
153
+
154
+ tools = _build_tools(session=session, mount_snapshot=mount_snapshot)
155
+ super().__init__(
156
+ title="Virtual Filesystem Tools",
157
+ key="vfs.tools",
158
+ template=_VFS_SECTION_TEMPLATE,
159
+ default_params=_VfsSectionParams(),
160
+ tools=tools,
161
+ )
162
+
163
+
164
+ def _build_tools(
165
+ *, session: Session, mount_snapshot: VirtualFileSystem
166
+ ) -> tuple[Tool[SupportsDataclass, SupportsDataclass], ...]:
167
+ suite = _VfsToolSuite(session=session, mount_snapshot=mount_snapshot)
168
+ return (
169
+ Tool[ListDirectory, ListDirectoryResult](
170
+ name="vfs_list_directory",
171
+ description="Enumerate files and directories at a path.",
172
+ handler=suite.list_directory,
173
+ ),
174
+ Tool[ReadFile, VfsFile](
175
+ name="vfs_read_file",
176
+ description="Read file contents and metadata.",
177
+ handler=suite.read_file,
178
+ ),
179
+ Tool[WriteFile, WriteFile](
180
+ name="vfs_write_file",
181
+ description="Create or update a file in the virtual filesystem.",
182
+ handler=suite.write_file,
183
+ ),
184
+ Tool[DeleteEntry, DeleteEntry](
185
+ name="vfs_delete_entry",
186
+ description="Delete a file or directory subtree.",
187
+ handler=suite.delete_entry,
188
+ ),
189
+ ) # type: ignore[return-value]
190
+
191
+
192
+ class _VfsToolSuite:
193
+ """Collection of VFS handlers bound to a session instance."""
194
+
195
+ def __init__(self, *, session: Session, mount_snapshot: VirtualFileSystem) -> None:
196
+ self._session = session
197
+ self._mount_snapshot = mount_snapshot
198
+
199
+ def list_directory(self, params: ListDirectory) -> ToolResult[ListDirectoryResult]:
200
+ target = _normalize_optional_path(params.path)
201
+ snapshot = self._latest_snapshot()
202
+ if _has_file(snapshot.files, target):
203
+ raise ToolValidationError("Cannot list a file path; provide a directory.")
204
+
205
+ directory_names: set[str] = set()
206
+ file_names: set[str] = set()
207
+ prefix_length = len(target.segments)
208
+ for file in snapshot.files:
209
+ segments = file.path.segments
210
+ if not _is_path_prefix(segments, target.segments):
211
+ continue
212
+ next_segment = segments[prefix_length]
213
+ if len(segments) == prefix_length + 1:
214
+ file_names.add(next_segment)
215
+ else:
216
+ directory_names.add(next_segment)
217
+
218
+ directories = tuple(sorted(directory_names))
219
+ files = tuple(sorted(file_names))
220
+ normalized = ListDirectoryResult(
221
+ path=target, directories=directories, files=files
222
+ )
223
+ message = _format_directory_message(target, directories, files)
224
+ return ToolResult(message=message, value=normalized)
225
+
226
+ def read_file(self, params: ReadFile) -> ToolResult[VfsFile]:
227
+ path = _normalize_required_path(params.path)
228
+ snapshot = self._latest_snapshot()
229
+ file = _find_file(snapshot.files, path)
230
+ if file is None:
231
+ raise ToolValidationError("File does not exist in the virtual filesystem.")
232
+ message = (
233
+ f"Read {file.size_bytes} bytes from {'/'.join(file.path.segments) or '.'}."
234
+ )
235
+ return ToolResult(message=message, value=file)
236
+
237
+ def write_file(self, params: WriteFile) -> ToolResult[WriteFile]:
238
+ path = _normalize_required_path(params.path)
239
+ if params.encoding != _DEFAULT_ENCODING:
240
+ raise ToolValidationError("Only UTF-8 encoding is supported.")
241
+ content = _normalize_content(params.content)
242
+ mode = params.mode
243
+ snapshot = self._latest_snapshot()
244
+ existing = _find_file(snapshot.files, path)
245
+ if mode == "create" and existing is not None:
246
+ raise ToolValidationError("File already exists; use overwrite or append.")
247
+ if mode in {"overwrite", "append"} and existing is None:
248
+ raise ToolValidationError("File does not exist for the requested mode.")
249
+ normalized = WriteFile(path=path, content=content, mode=mode)
250
+ action = {
251
+ "create": "created",
252
+ "overwrite": "overwritten",
253
+ "append": "appended",
254
+ }[mode]
255
+ message = f"File {'/'.join(path.segments) or '.'} {action}."
256
+ return ToolResult(message=message, value=normalized)
257
+
258
+ def delete_entry(self, params: DeleteEntry) -> ToolResult[DeleteEntry]:
259
+ path = _normalize_path(params.path)
260
+ snapshot = self._latest_snapshot()
261
+ deleted_count = _count_matching(snapshot.files, path)
262
+ if deleted_count == 0:
263
+ raise ToolValidationError("No files matched the provided path.")
264
+ normalized = DeleteEntry(path=path)
265
+ message = f"Deleted {deleted_count} entr{'ies' if deleted_count != 1 else 'y'}."
266
+ return ToolResult(message=message, value=normalized)
267
+
268
+ def _latest_snapshot(self) -> VirtualFileSystem:
269
+ snapshot = select_latest(self._session, VirtualFileSystem)
270
+ if snapshot is not None:
271
+ return snapshot
272
+ return (
273
+ self._mount_snapshot if self._mount_snapshot.files else VirtualFileSystem()
274
+ )
275
+
276
+
277
+ def _normalize_content(content: str) -> str:
278
+ if len(content) > _MAX_WRITE_LENGTH:
279
+ raise ToolValidationError(
280
+ "Content exceeds maximum length of 48,000 characters."
281
+ )
282
+ _ensure_ascii(content, "content")
283
+ return content
284
+
285
+
286
+ def _normalize_optional_path(path: VfsPath | None) -> VfsPath:
287
+ if path is None:
288
+ return VfsPath(())
289
+ return _normalize_path(path)
290
+
291
+
292
+ def _normalize_required_path(path: VfsPath) -> VfsPath:
293
+ normalized = _normalize_path(path)
294
+ if not normalized.segments:
295
+ raise ToolValidationError("Path must reference a file or directory.")
296
+ return normalized
297
+
298
+
299
+ def _normalize_path(path: VfsPath) -> VfsPath:
300
+ segments = _normalize_segments(path.segments)
301
+ if len(segments) > _MAX_PATH_DEPTH:
302
+ raise ToolValidationError("Path depth exceeds the allowed limit (16 segments).")
303
+ return VfsPath(segments)
304
+
305
+
306
+ def _normalize_segments(raw_segments: Sequence[str]) -> tuple[str, ...]:
307
+ segments: list[str] = []
308
+ for raw_segment in raw_segments:
309
+ cleaned_segment = raw_segment.strip()
310
+ if not cleaned_segment:
311
+ continue
312
+ if cleaned_segment.startswith("/"):
313
+ raise ToolValidationError("Absolute paths are not allowed in the VFS.")
314
+ for piece in cleaned_segment.split("/"):
315
+ if not piece:
316
+ continue
317
+ if piece in {".", ".."}:
318
+ raise ToolValidationError("Path segments may not include '.' or '..'.")
319
+ _ensure_ascii(piece, "path segment")
320
+ if len(piece) > _MAX_SEGMENT_LENGTH:
321
+ raise ToolValidationError(
322
+ "Path segments must be 80 characters or fewer."
323
+ )
324
+ segments.append(piece)
325
+ return tuple(segments)
326
+
327
+
328
+ def _ensure_ascii(value: str, field: str) -> None:
329
+ try:
330
+ value.encode(_ASCII)
331
+ except UnicodeEncodeError as error: # pragma: no cover - defensive guard
332
+ raise ToolValidationError(
333
+ f"{field.capitalize()} must be ASCII text."
334
+ ) from error
335
+
336
+
337
+ def _has_file(files: Iterable[VfsFile], path: VfsPath) -> bool:
338
+ return _find_file(files, path) is not None
339
+
340
+
341
+ def _find_file(files: Iterable[VfsFile], path: VfsPath) -> VfsFile | None:
342
+ target = path.segments
343
+ for file in files:
344
+ if file.path.segments == target:
345
+ return file
346
+ return None
347
+
348
+
349
+ def _is_path_prefix(path: Sequence[str], prefix: Sequence[str]) -> bool:
350
+ if len(path) < len(prefix):
351
+ return False
352
+ return all(path[index] == prefix[index] for index in range(len(prefix)))
353
+
354
+
355
+ def _count_matching(files: Iterable[VfsFile], path: VfsPath) -> int:
356
+ target = path.segments
357
+ return sum(1 for file in files if _is_path_prefix(file.path.segments, target))
358
+
359
+
360
+ def _format_directory_message(
361
+ path: VfsPath, directories: tuple[str, ...], files: tuple[str, ...]
362
+ ) -> str:
363
+ prefix = "/".join(path.segments) or "."
364
+ directory_count = len(directories)
365
+ file_count = len(files)
366
+ return (
367
+ f"Directory {prefix} contains {directory_count} subdirector"
368
+ f"{'ies' if directory_count != 1 else 'y'} and {file_count} file"
369
+ f"{'s' if file_count != 1 else ''}."
370
+ )
371
+
372
+
373
+ def _normalize_root(path: os.PathLike[str] | str) -> Path:
374
+ root = Path(path).expanduser().resolve()
375
+ if not root.exists():
376
+ raise ToolValidationError("Allowed host root does not exist.")
377
+ return root
378
+
379
+
380
+ def _materialize_mounts(
381
+ mounts: Sequence[HostMount], allowed_roots: Sequence[Path]
382
+ ) -> VirtualFileSystem:
383
+ if not mounts:
384
+ return VirtualFileSystem()
385
+
386
+ aggregated: dict[tuple[str, ...], VfsFile] = {}
387
+ for mount in mounts:
388
+ loaded = _load_mount(mount, allowed_roots)
389
+ for file in loaded:
390
+ aggregated[file.path.segments] = file
391
+ files = tuple(sorted(aggregated.values(), key=lambda file: file.path.segments))
392
+ return VirtualFileSystem(files=files)
393
+
394
+
395
+ def _load_mount(mount: HostMount, allowed_roots: Sequence[Path]) -> tuple[VfsFile, ...]:
396
+ host_path = mount.host_path.strip()
397
+ if not host_path:
398
+ raise ToolValidationError("Host mount path must not be empty.")
399
+ _ensure_ascii(host_path, "host path")
400
+ resolved_host = _resolve_mount_path(host_path, allowed_roots)
401
+ include_patterns = _normalize_globs(mount.include_glob, "include_glob")
402
+ exclude_patterns = _normalize_globs(mount.exclude_glob, "exclude_glob")
403
+ mount_prefix = _normalize_optional_path(mount.mount_path)
404
+
405
+ files: list[VfsFile] = []
406
+ consumed_bytes = 0
407
+ timestamp = _now()
408
+ for path in _iter_mount_files(resolved_host, mount.follow_symlinks):
409
+ relative = (
410
+ Path(path.name)
411
+ if resolved_host.is_file()
412
+ else path.relative_to(resolved_host)
413
+ )
414
+ relative_posix = relative.as_posix()
415
+ if include_patterns and not any(
416
+ fnmatch.fnmatchcase(relative_posix, pattern) for pattern in include_patterns
417
+ ):
418
+ continue
419
+ if any(
420
+ fnmatch.fnmatchcase(relative_posix, pattern) for pattern in exclude_patterns
421
+ ):
422
+ continue
423
+
424
+ try:
425
+ content = path.read_text(encoding=_DEFAULT_ENCODING)
426
+ except UnicodeDecodeError as error: # pragma: no cover - defensive guard
427
+ raise ToolValidationError("Mounted file must be valid UTF-8.") from error
428
+ _ensure_ascii(content, "mounted file content")
429
+ size = len(content.encode(_DEFAULT_ENCODING))
430
+ if mount.max_bytes is not None and consumed_bytes + size > mount.max_bytes:
431
+ raise ToolValidationError("Host mount exceeded the configured byte budget.")
432
+ consumed_bytes += size
433
+
434
+ segments = mount_prefix.segments + relative.parts
435
+ normalized_path = _normalize_path(VfsPath(segments))
436
+ file = VfsFile(
437
+ path=normalized_path,
438
+ content=content,
439
+ encoding=_DEFAULT_ENCODING,
440
+ size_bytes=size,
441
+ version=1,
442
+ created_at=timestamp,
443
+ updated_at=timestamp,
444
+ )
445
+ files.append(file)
446
+ return tuple(files)
447
+
448
+
449
+ def _resolve_mount_path(host_path: str, allowed_roots: Sequence[Path]) -> Path:
450
+ if not allowed_roots:
451
+ raise ToolValidationError("No allowed host roots configured for mounts.")
452
+ for root in allowed_roots:
453
+ candidate = (root / host_path).resolve()
454
+ try:
455
+ candidate.relative_to(root)
456
+ except ValueError:
457
+ continue
458
+ if candidate.exists():
459
+ return candidate
460
+ raise ToolValidationError("Host path is outside the allowed roots or missing.")
461
+
462
+
463
+ def _normalize_globs(patterns: Sequence[str], field: str) -> tuple[str, ...]:
464
+ normalized: list[str] = []
465
+ for pattern in patterns:
466
+ stripped = pattern.strip()
467
+ if not stripped:
468
+ continue
469
+ _ensure_ascii(stripped, field)
470
+ normalized.append(stripped)
471
+ return tuple(normalized)
472
+
473
+
474
+ def _iter_mount_files(root: Path, follow_symlinks: bool) -> Iterable[Path]:
475
+ if root.is_file():
476
+ yield root
477
+ return
478
+ for dirpath, _dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
479
+ current = Path(dirpath)
480
+ for name in filenames:
481
+ yield current / name
482
+
483
+
484
+ def _make_write_reducer(
485
+ mount_snapshot: VirtualFileSystem,
486
+ ) -> Callable[
487
+ [tuple[VirtualFileSystem, ...], DataEvent], tuple[VirtualFileSystem, ...]
488
+ ]:
489
+ def reducer(
490
+ slice_values: tuple[VirtualFileSystem, ...], event: DataEvent
491
+ ) -> tuple[VirtualFileSystem, ...]:
492
+ previous = _latest_virtual_filesystem(slice_values, mount_snapshot)
493
+ params = cast(WriteFile, event.value)
494
+ timestamp = _now()
495
+ files = list(previous.files)
496
+ existing_index = _index_of(files, params.path)
497
+ existing = files[existing_index] if existing_index is not None else None
498
+ if params.mode == "append" and existing is not None:
499
+ content = existing.content + params.content
500
+ created_at = existing.created_at
501
+ version = existing.version + 1
502
+ elif existing is not None:
503
+ content = params.content
504
+ created_at = existing.created_at
505
+ version = existing.version + 1
506
+ else:
507
+ content = params.content
508
+ created_at = timestamp
509
+ version = 1
510
+ size = len(content.encode(_DEFAULT_ENCODING))
511
+ updated_file = VfsFile(
512
+ path=params.path,
513
+ content=content,
514
+ encoding=_DEFAULT_ENCODING,
515
+ size_bytes=size,
516
+ version=version,
517
+ created_at=_truncate_to_milliseconds(created_at),
518
+ updated_at=_truncate_to_milliseconds(timestamp),
519
+ )
520
+ if existing_index is not None:
521
+ del files[existing_index]
522
+ files.append(updated_file)
523
+ files.sort(key=lambda file: file.path.segments)
524
+ snapshot = VirtualFileSystem(files=tuple(files))
525
+ return (snapshot,)
526
+
527
+ return reducer
528
+
529
+
530
+ def _make_delete_reducer(
531
+ mount_snapshot: VirtualFileSystem,
532
+ ) -> Callable[
533
+ [tuple[VirtualFileSystem, ...], DataEvent], tuple[VirtualFileSystem, ...]
534
+ ]:
535
+ def reducer(
536
+ slice_values: tuple[VirtualFileSystem, ...], event: DataEvent
537
+ ) -> tuple[VirtualFileSystem, ...]:
538
+ previous = _latest_virtual_filesystem(slice_values, mount_snapshot)
539
+ params = cast(DeleteEntry, event.value)
540
+ target = params.path.segments
541
+ files = [
542
+ file
543
+ for file in previous.files
544
+ if not _is_path_prefix(file.path.segments, target)
545
+ ]
546
+ files.sort(key=lambda file: file.path.segments)
547
+ snapshot = VirtualFileSystem(files=tuple(files))
548
+ return (snapshot,)
549
+
550
+ return reducer
551
+
552
+
553
+ def _latest_virtual_filesystem(
554
+ values: tuple[VirtualFileSystem, ...], mount_snapshot: VirtualFileSystem
555
+ ) -> VirtualFileSystem:
556
+ if values:
557
+ return values[-1]
558
+ if mount_snapshot.files:
559
+ return mount_snapshot
560
+ return VirtualFileSystem()
561
+
562
+
563
+ def _index_of(files: list[VfsFile], path: VfsPath) -> int | None:
564
+ for index, file in enumerate(files):
565
+ if file.path.segments == path.segments:
566
+ return index
567
+ return None
568
+
569
+
570
+ def _now() -> datetime:
571
+ return _truncate_to_milliseconds(datetime.now(UTC))
572
+
573
+
574
+ def _truncate_to_milliseconds(value: datetime) -> datetime:
575
+ microsecond = value.microsecond - (value.microsecond % 1000)
576
+ return value.replace(microsecond=microsecond, tzinfo=UTC)
577
+
578
+
579
+ __all__ = [
580
+ "VirtualFileSystem",
581
+ "VfsFile",
582
+ "VfsPath",
583
+ "HostMount",
584
+ "ListDirectory",
585
+ "ListDirectoryResult",
586
+ "ReadFile",
587
+ "WriteFile",
588
+ "DeleteEntry",
589
+ "VfsToolsSection",
590
+ ]