monkeyfs 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/PKG-INFO +2 -2
  2. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/README.md +1 -1
  3. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/conformance.py +10 -1
  4. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/mount.py +2 -1
  5. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/virtual.py +40 -10
  6. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs.egg-info/PKG-INFO +2 -2
  7. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/pyproject.toml +1 -1
  8. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_directory_ops.py +48 -0
  9. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_metadata.py +67 -0
  10. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_open_optional.py +1 -1
  11. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/LICENSE +0 -0
  12. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/__init__.py +0 -0
  13. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/base.py +0 -0
  14. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/context.py +0 -0
  15. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/isolated.py +0 -0
  16. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/patching/__init__.py +0 -0
  17. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/patching/core.py +0 -0
  18. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/patching/fdtable.py +0 -0
  19. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/patching/install.py +0 -0
  20. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/patching/patches.py +0 -0
  21. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/py.typed +0 -0
  22. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/readonly.py +0 -0
  23. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs/virtualfile.py +0 -0
  24. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs.egg-info/SOURCES.txt +0 -0
  25. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs.egg-info/dependency_links.txt +0 -0
  26. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs.egg-info/requires.txt +0 -0
  27. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/monkeyfs.egg-info/top_level.txt +0 -0
  28. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/setup.cfg +0 -0
  29. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_bulk_operations.py +0 -0
  30. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_conformance.py +0 -0
  31. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_expanduser.py +0 -0
  32. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_fd_emulation.py +0 -0
  33. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_finalization_and_invalidate.py +0 -0
  34. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_isolated.py +0 -0
  35. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_lazy_binary_read.py +0 -0
  36. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_mount.py +0 -0
  37. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_patching.py +0 -0
  38. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_patching_pathlib.py +0 -0
  39. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_patching_safe_paths.py +0 -0
  40. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_protocol.py +0 -0
  41. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_ranged_read.py +0 -0
  42. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_readonly.py +0 -0
  43. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_symlink_entries.py +0 -0
  44. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_vfs_optional.py +0 -0
  45. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_vfs_size_limit.py +0 -0
  46. {monkeyfs-0.2.1 → monkeyfs-0.2.3}/tests/test_virtual.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monkeyfs
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Transparent filesystem interception via monkey-patching.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -142,7 +142,7 @@ It raises `AssertionError` naming the method and what was expected of it -- incl
142
142
 
143
143
  ## Part of the agex stack
144
144
 
145
- monkeyfs provides filesystem interception for [sandtrap](https://github.com/ashenfad/sandtrap) and [agex](https://github.com/ashenfad/agex), giving sandboxed agent code an isolated virtual filesystem. `VirtualFS` accepts any dict-like backing store -- including [kvgit](https://github.com/ashenfad/kvgit) `Staged` instances for a versioned filesystem with commit/rollback.
145
+ monkeyfs provides filesystem interception for [sandtrap](https://github.com/ashenfad/sandtrap) and [agex](https://github.com/ashenfad/agex), giving sandboxed agent code an isolated virtual filesystem. `VirtualFS` accepts any dict-like backing store -- including a [kvgit](https://github.com/ashenfad/kvgit) worktree for a versioned filesystem with commit/rollback. A store that offers `get_many(*keys)`, as kvgit worktrees do, has its metadata read in one batched call rather than one per file.
146
146
 
147
147
  ## Documentation
148
148
 
@@ -108,7 +108,7 @@ It raises `AssertionError` naming the method and what was expected of it -- incl
108
108
 
109
109
  ## Part of the agex stack
110
110
 
111
- monkeyfs provides filesystem interception for [sandtrap](https://github.com/ashenfad/sandtrap) and [agex](https://github.com/ashenfad/agex), giving sandboxed agent code an isolated virtual filesystem. `VirtualFS` accepts any dict-like backing store -- including [kvgit](https://github.com/ashenfad/kvgit) `Staged` instances for a versioned filesystem with commit/rollback.
111
+ monkeyfs provides filesystem interception for [sandtrap](https://github.com/ashenfad/sandtrap) and [agex](https://github.com/ashenfad/agex), giving sandboxed agent code an isolated virtual filesystem. `VirtualFS` accepts any dict-like backing store -- including a [kvgit](https://github.com/ashenfad/kvgit) worktree for a versioned filesystem with commit/rollback. A store that offers `get_many(*keys)`, as kvgit worktrees do, has its metadata read in one batched call rather than one per file.
112
112
 
113
113
  ## Documentation
114
114
 
@@ -212,12 +212,21 @@ def _check_listing_paths(fs: Any, scratch: str) -> None:
212
212
  f"{absolute.get('probe.bin')!r}",
213
213
  )
214
214
 
215
- deep = [info.path for info in fs.list_detailed(scratch, True)]
215
+ entries = fs.list_detailed(scratch, True)
216
+ deep = [info.path for info in entries]
216
217
  _require(
217
218
  f"{scratch}/nested/leaf.bin" in deep,
218
219
  f"a recursive list_detailed({scratch!r}) must name a nested entry "
219
220
  f"{scratch + '/nested/leaf.bin'!r}, got {deep!r}",
220
221
  )
222
+ for info in entries:
223
+ _require(
224
+ info.name == info.path.rsplit("/", 1)[-1],
225
+ f"list_detailed(...).name is the entry's own name, the last "
226
+ f"component of its path, at every depth: for path "
227
+ f"{info.path!r} expected {info.path.rsplit('/', 1)[-1]!r}, "
228
+ f"got {info.name!r}",
229
+ )
221
230
 
222
231
  start = fs.getcwd()
223
232
  fs.chdir(scratch)
@@ -285,10 +285,11 @@ class MountFS:
285
285
  for name in names:
286
286
  full_abs = f"{norm}/{name}" if norm else f"/{name}"
287
287
  display = f"{user_prefix}/{name}" if user_prefix != "." else name
288
+ basename = name.rsplit("/", 1)[-1]
288
289
  meta = self.stat(full_abs)
289
290
  result.append(
290
291
  FileInfo(
291
- name=name,
292
+ name=basename,
292
293
  path=display,
293
294
  size=meta.size,
294
295
  created_at=meta.created_at,
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  from collections.abc import MutableMapping
12
12
  from datetime import datetime, timezone
13
+ from typing import Any
13
14
 
14
15
  from .base import FileInfo, FileMetadata
15
16
  from .virtualfile import LazyBinaryFile, VirtualFile, open_file
@@ -19,7 +20,8 @@ class VirtualFS:
19
20
  """State-backed virtual filesystem with metadata tracking.
20
21
 
21
22
  Provides file operations backed by agent state. Each file is stored
22
- as a separate state key, enabling granular versioning with Staged state.
23
+ as a separate state key, enabling granular versioning with a versioned
24
+ state such as a kvgit worktree.
23
25
 
24
26
  File metadata (size, creation time, modification time) is automatically
25
27
  tracked for all files and can be accessed via stat() or list_detailed().
@@ -77,6 +79,11 @@ class VirtualFS:
77
79
 
78
80
  Args:
79
81
  state: State backend for file storage. Defaults to an empty dict.
82
+ A state may also offer ``get_many(*keys)``, returning a
83
+ mapping of the keys that exist to their values (as kvgit
84
+ worktrees do). Scans that read many keys then ask for them
85
+ in one call, which over a networked store is one round
86
+ trip instead of one per key.
80
87
  max_size_mb: Maximum total size of all files in megabytes.
81
88
  None means unlimited.
82
89
  """
@@ -417,10 +424,10 @@ class VirtualFS:
417
424
  return self._all_meta
418
425
 
419
426
  rows: dict[str, FileMetadata] = {}
420
- for key in list(self._state.keys()):
421
- if not self.is_metadata_key(key):
422
- continue
423
- raw = self._state.get(key)
427
+ keys = [key for key in list(self._state.keys()) if self.is_metadata_key(key)]
428
+ found = self._read_many(keys)
429
+ for key in keys:
430
+ raw = found.get(key)
424
431
  if raw is None:
425
432
  continue
426
433
  try:
@@ -436,6 +443,24 @@ class VirtualFS:
436
443
  self._all_meta = rows
437
444
  return rows
438
445
 
446
+ def _read_many(self, keys: list[str]) -> dict[str, Any]:
447
+ """The values of ``keys`` that exist in the state.
448
+
449
+ One ``get_many`` call when the state offers it, else one ``get``
450
+ per key.
451
+ """
452
+ if not keys:
453
+ return {}
454
+ get_many = getattr(self._state, "get_many", None)
455
+ if callable(get_many):
456
+ return dict(get_many(*keys))
457
+ found: dict[str, Any] = {}
458
+ for key in keys:
459
+ value = self._state.get(key)
460
+ if value is not None:
461
+ found[key] = value
462
+ return found
463
+
439
464
  def _get_current_size(self) -> int:
440
465
  """Get total size of all files in the VFS.
441
466
 
@@ -1325,7 +1350,10 @@ class VirtualFS:
1325
1350
  recursive: If True, list all nested files and directories.
1326
1351
 
1327
1352
  Returns:
1328
- List of FileInfo objects sorted by name.
1353
+ List of FileInfo objects sorted by path relative to the queried
1354
+ directory, so a directory precedes its contents. ``name`` is the
1355
+ entry's own name, the last component of ``path``, at every depth;
1356
+ it is not what the list is ordered by.
1329
1357
 
1330
1358
  Example:
1331
1359
  >>> files = vfs.list_detailed("/shared")
@@ -1350,7 +1378,9 @@ class VirtualFS:
1350
1378
  internal_path = f"{base}/{name}" if base else name
1351
1379
  absolute = "/" + internal_path
1352
1380
 
1353
- # Display path preserves the user's queried prefix
1381
+ # Display path preserves the user's queried prefix; the name
1382
+ # is the last component alone, whatever depth the entry sits at.
1383
+ basename = name.rsplit("/", 1)[-1]
1354
1384
  display = f"{user_prefix}/{name}" if user_prefix != "." else name
1355
1385
 
1356
1386
  meta = self._meta_at(internal_path)
@@ -1360,7 +1390,7 @@ class VirtualFS:
1360
1390
  now = self._now_iso()
1361
1391
  result.append(
1362
1392
  FileInfo(
1363
- name=name,
1393
+ name=basename,
1364
1394
  path=display,
1365
1395
  size=0,
1366
1396
  created_at=meta.created_at if meta is not None else now,
@@ -1371,7 +1401,7 @@ class VirtualFS:
1371
1401
  elif meta is not None:
1372
1402
  result.append(
1373
1403
  FileInfo(
1374
- name=name,
1404
+ name=basename,
1375
1405
  path=display,
1376
1406
  size=meta.size,
1377
1407
  created_at=meta.created_at,
@@ -1385,7 +1415,7 @@ class VirtualFS:
1385
1415
  now = self._now_iso()
1386
1416
  result.append(
1387
1417
  FileInfo(
1388
- name=name,
1418
+ name=basename,
1389
1419
  path=display,
1390
1420
  size=len(content),
1391
1421
  created_at=now,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monkeyfs
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Transparent filesystem interception via monkey-patching.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -142,7 +142,7 @@ It raises `AssertionError` naming the method and what was expected of it -- incl
142
142
 
143
143
  ## Part of the agex stack
144
144
 
145
- monkeyfs provides filesystem interception for [sandtrap](https://github.com/ashenfad/sandtrap) and [agex](https://github.com/ashenfad/agex), giving sandboxed agent code an isolated virtual filesystem. `VirtualFS` accepts any dict-like backing store -- including [kvgit](https://github.com/ashenfad/kvgit) `Staged` instances for a versioned filesystem with commit/rollback.
145
+ monkeyfs provides filesystem interception for [sandtrap](https://github.com/ashenfad/sandtrap) and [agex](https://github.com/ashenfad/agex), giving sandboxed agent code an isolated virtual filesystem. `VirtualFS` accepts any dict-like backing store -- including a [kvgit](https://github.com/ashenfad/kvgit) worktree for a versioned filesystem with commit/rollback. A store that offers `get_many(*keys)`, as kvgit worktrees do, has its metadata read in one batched call rather than one per file.
146
146
 
147
147
  ## Documentation
148
148
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "monkeyfs"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Transparent filesystem interception via monkey-patching."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -466,3 +466,51 @@ class TestImplicitDirectories:
466
466
  assert vfs.list("/") == ["pkg", "top.py"]
467
467
  assert vfs.list("pkg") == ["__init__.py", "sub", "utils.py"]
468
468
  assert vfs.list("pkg/sub") == ["mod.py"]
469
+
470
+
471
+ # -- list_detailed().name is the entry's own name at every depth ---------------
472
+
473
+
474
+ def _nested_tree(fs):
475
+ fs.makedirs("/probe/sub")
476
+ fs.write("/probe/one.txt", b"1")
477
+ fs.write("/probe/sub/two.txt", b"2")
478
+ return fs
479
+
480
+
481
+ def _names_and_paths(fs):
482
+ # The sequence as returned, not a sorted copy: every backend orders a
483
+ # recursive listing by path relative to the queried directory, so a
484
+ # directory precedes its contents, and a backend that drifts from
485
+ # that order fails here rather than being tidied into agreement.
486
+ return [(i.name, i.path) for i in fs.list_detailed("/probe", recursive=True)]
487
+
488
+
489
+ def test_virtualfs_recursive_listing_names_are_basenames():
490
+ from monkeyfs import VirtualFS
491
+
492
+ assert _names_and_paths(_nested_tree(VirtualFS({}))) == [
493
+ ("one.txt", "/probe/one.txt"),
494
+ ("sub", "/probe/sub"),
495
+ ("two.txt", "/probe/sub/two.txt"),
496
+ ]
497
+
498
+
499
+ def test_mountfs_recursive_listing_names_are_basenames():
500
+ from monkeyfs import MountFS, VirtualFS
501
+
502
+ assert _names_and_paths(_nested_tree(MountFS(VirtualFS({})))) == [
503
+ ("one.txt", "/probe/one.txt"),
504
+ ("sub", "/probe/sub"),
505
+ ("two.txt", "/probe/sub/two.txt"),
506
+ ]
507
+
508
+
509
+ def test_isolatedfs_recursive_listing_names_are_basenames(tmp_path):
510
+ from monkeyfs import IsolatedFS
511
+
512
+ assert _names_and_paths(_nested_tree(IsolatedFS(str(tmp_path)))) == [
513
+ ("one.txt", "/probe/one.txt"),
514
+ ("sub", "/probe/sub"),
515
+ ("two.txt", "/probe/sub/two.txt"),
516
+ ]
@@ -707,3 +707,70 @@ class TestLegacyTableMigration:
707
707
  assert vfs.isdir("d")
708
708
  assert set(rows(state)) == {"d/a.txt"}
709
709
  assert set(legacy_table(state)) == {"d"}
710
+
711
+
712
+ class BatchingState(dict):
713
+ """A dict that also answers ``get_many``, counting how it is read."""
714
+
715
+ def __init__(self, *args, **kwargs):
716
+ super().__init__(*args, **kwargs)
717
+ self.gets: list[str] = []
718
+ self.batches: list[tuple[str, ...]] = []
719
+
720
+ def get(self, key, default=None):
721
+ self.gets.append(key)
722
+ return super().get(key, default)
723
+
724
+ def get_many(self, *keys):
725
+ self.batches.append(keys)
726
+ return {k: dict.__getitem__(self, k) for k in keys if k in self}
727
+
728
+
729
+ def _tree(vfs):
730
+ vfs.mkdir("/empty")
731
+ for i in range(30):
732
+ vfs.write(f"/d{i % 3}/f{i}.txt", b"x" * i)
733
+
734
+
735
+ class TestBatchedMetadataScan:
736
+ def test_a_scan_reads_every_row_in_one_call(self):
737
+ state = BatchingState()
738
+ _tree(VirtualFS(state))
739
+ state.gets.clear()
740
+
741
+ fresh = VirtualFS(state) # nothing cached
742
+ fresh.list("/", recursive=True)
743
+
744
+ assert len(state.batches) == 1
745
+ assert set(state.batches[0]) == {
746
+ k for k in state if VirtualFS.is_metadata_key(k)
747
+ }
748
+ assert not [k for k in state.gets if VirtualFS.is_metadata_key(k)]
749
+
750
+ def test_a_batching_state_answers_as_a_plain_one_does(self):
751
+ plain: dict = {}
752
+ _tree(VirtualFS(plain))
753
+ batching = BatchingState(plain)
754
+
755
+ def listing(state):
756
+ vfs = VirtualFS(state)
757
+ return [
758
+ (info.path, info.size, info.is_dir)
759
+ for info in vfs.list_detailed("/", recursive=True)
760
+ ]
761
+
762
+ assert listing(batching) == listing(plain)
763
+ assert (
764
+ VirtualFS(batching)._get_current_size()
765
+ == VirtualFS(plain)._get_current_size()
766
+ )
767
+
768
+ def test_the_legacy_table_still_fills_paths_without_rows(self):
769
+ state = BatchingState(
770
+ _legacy_state({"/old.txt": _entry(3)}, blobs={"/old.txt": b"abc"})
771
+ )
772
+ vfs = VirtualFS(state)
773
+ vfs.write("/new.txt", b"hello")
774
+ fresh = VirtualFS(state)
775
+ assert fresh.stat("/old.txt").size == 3
776
+ assert {i.path for i in fresh.list_detailed("/")} == {"/old.txt", "/new.txt"}
@@ -190,7 +190,7 @@ class TermishFS:
190
190
  meta = self.stat(full)
191
191
  entries.append(
192
192
  FileInfo(
193
- name=name,
193
+ name=posixpath.basename(name),
194
194
  path=display,
195
195
  size=meta.size,
196
196
  created_at=meta.created_at,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes