monkeyfs 0.2.3__tar.gz → 0.2.5__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.
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/PKG-INFO +1 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/base.py +8 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/isolated.py +17 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/mount.py +38 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/virtual.py +55 -15
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs.egg-info/PKG-INFO +1 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/pyproject.toml +1 -1
- monkeyfs-0.2.5/tests/test_bulk_operations.py +239 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_directory_ops.py +60 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_readonly.py +1 -0
- monkeyfs-0.2.3/tests/test_bulk_operations.py +0 -93
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/LICENSE +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/README.md +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/__init__.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/conformance.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/context.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/patching/__init__.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/patching/core.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/patching/fdtable.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/patching/install.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/patching/patches.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/py.typed +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/readonly.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs/virtualfile.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs.egg-info/SOURCES.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs.egg-info/dependency_links.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs.egg-info/requires.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/monkeyfs.egg-info/top_level.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/setup.cfg +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_conformance.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_expanduser.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_fd_emulation.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_finalization_and_invalidate.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_isolated.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_lazy_binary_read.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_metadata.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_mount.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_open_optional.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_patching.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_patching_pathlib.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_patching_safe_paths.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_protocol.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_ranged_read.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_symlink_entries.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_vfs_optional.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_vfs_size_limit.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.5}/tests/test_virtual.py +0 -0
|
@@ -162,7 +162,14 @@ OPTIONAL_WRITE_METHODS = frozenset(
|
|
|
162
162
|
#: termish's shell over a filesystem. Not dispatched by any stdlib shim, but
|
|
163
163
|
#: composable — a wrapper that routes by path can forward them.
|
|
164
164
|
DIRECT_READ_METHODS = frozenset(
|
|
165
|
-
{
|
|
165
|
+
{
|
|
166
|
+
"get_metadata_snapshot",
|
|
167
|
+
"glob",
|
|
168
|
+
"invalidate",
|
|
169
|
+
"lexists",
|
|
170
|
+
"list_detailed",
|
|
171
|
+
"read_many",
|
|
172
|
+
}
|
|
166
173
|
)
|
|
167
174
|
DIRECT_WRITE_METHODS = frozenset({"remove_many", "write_many"})
|
|
168
175
|
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import io
|
|
9
9
|
import os
|
|
10
|
+
from collections.abc import Iterable
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
@@ -557,6 +558,22 @@ class IsolatedFS:
|
|
|
557
558
|
"""Create directory tree (alias for mkdir with parents=True)."""
|
|
558
559
|
self.mkdir(path, parents=True, exist_ok=exist_ok)
|
|
559
560
|
|
|
561
|
+
def read_many(self, paths: Iterable[str]) -> dict[str, bytes]:
|
|
562
|
+
"""Read several whole files: path (as given) to bytes, for the
|
|
563
|
+
paths that are files. A path that is not one is left out."""
|
|
564
|
+
found: dict[str, bytes] = {}
|
|
565
|
+
for path in paths:
|
|
566
|
+
# Regular files only (through a link, as ``isfile`` follows
|
|
567
|
+
# one): a socket cannot be read and a FIFO would block
|
|
568
|
+
# waiting for a writer.
|
|
569
|
+
if not self.isfile(path):
|
|
570
|
+
continue
|
|
571
|
+
try:
|
|
572
|
+
found[path] = self.read(path)
|
|
573
|
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
|
|
574
|
+
continue # gone between the check and the read
|
|
575
|
+
return found
|
|
576
|
+
|
|
560
577
|
def write_many(self, files: dict[str, bytes]) -> None:
|
|
561
578
|
"""Write multiple files at once."""
|
|
562
579
|
for path, content in files.items():
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import errno
|
|
11
11
|
import os
|
|
12
|
+
from collections.abc import Iterable
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -16,6 +17,22 @@ from .base import FileInfo, FileMetadata
|
|
|
16
17
|
from .virtualfile import open_file
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def _read_many(fs: Any, paths: list[str]) -> dict[str, bytes]:
|
|
21
|
+
"""``fs.read_many(paths)``, or one ``read`` per path on a filesystem
|
|
22
|
+
that predates it -- a mount can be any backend, and ``read_many`` is
|
|
23
|
+
not one of the methods a backend has to have."""
|
|
24
|
+
batch = getattr(fs, "read_many", None)
|
|
25
|
+
if batch is not None:
|
|
26
|
+
return batch(paths)
|
|
27
|
+
found: dict[str, bytes] = {}
|
|
28
|
+
for path in paths:
|
|
29
|
+
try:
|
|
30
|
+
found[path] = fs.read(path)
|
|
31
|
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
|
|
32
|
+
continue
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
|
|
19
36
|
class MountFS:
|
|
20
37
|
"""Routes filesystem operations to backing filesystems by path prefix.
|
|
21
38
|
|
|
@@ -389,6 +406,27 @@ class MountFS:
|
|
|
389
406
|
if drop is not None:
|
|
390
407
|
drop()
|
|
391
408
|
|
|
409
|
+
def read_many(self, paths: Iterable[str]) -> dict[str, bytes]:
|
|
410
|
+
# Group by filesystem, so each one answers its share in one call
|
|
411
|
+
groups: dict[int, tuple[Any, dict[str, list[str]]]] = {}
|
|
412
|
+
for path in paths:
|
|
413
|
+
if self._is_mount_point(self._to_absolute(path)):
|
|
414
|
+
# A mount point, or a directory the composition makes on
|
|
415
|
+
# the way to one: never a file here, whatever the base
|
|
416
|
+
# holds under the same name, just as ``isfile`` says.
|
|
417
|
+
continue
|
|
418
|
+
fs, inner = self._resolve(path)
|
|
419
|
+
key = id(fs)
|
|
420
|
+
if key not in groups:
|
|
421
|
+
groups[key] = (fs, {})
|
|
422
|
+
groups[key][1].setdefault(inner, []).append(path)
|
|
423
|
+
found: dict[str, bytes] = {}
|
|
424
|
+
for fs, inner_paths in groups.values():
|
|
425
|
+
for inner, content in _read_many(fs, list(inner_paths)).items():
|
|
426
|
+
for path in inner_paths[inner]:
|
|
427
|
+
found[path] = content
|
|
428
|
+
return found
|
|
429
|
+
|
|
392
430
|
# -- Write operations --
|
|
393
431
|
|
|
394
432
|
def write(self, path: str, content: bytes, mode: str = "w") -> None:
|
|
@@ -8,7 +8,7 @@ import fnmatch
|
|
|
8
8
|
import io
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
-
from collections.abc import MutableMapping
|
|
11
|
+
from collections.abc import Iterable, MutableMapping
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
@@ -536,6 +536,18 @@ class VirtualFS:
|
|
|
536
536
|
"""
|
|
537
537
|
return dict(self._meta_all())
|
|
538
538
|
|
|
539
|
+
@staticmethod
|
|
540
|
+
def _absolute(normalized: str) -> str:
|
|
541
|
+
"""A normalized key as an absolute path, for handing back to a
|
|
542
|
+
method that resolves its argument.
|
|
543
|
+
|
|
544
|
+
``resolve_path`` returns the key form, which has no leading
|
|
545
|
+
slash; passed on as it is, the next method reads it as relative
|
|
546
|
+
to the working directory and names a different path whenever
|
|
547
|
+
that is not ``/``.
|
|
548
|
+
"""
|
|
549
|
+
return "/" + normalized.strip("/")
|
|
550
|
+
|
|
539
551
|
def _normalize_path(self, path: str) -> str:
|
|
540
552
|
"""Normalize file path for consistent internal keys.
|
|
541
553
|
|
|
@@ -678,6 +690,30 @@ class VirtualFS:
|
|
|
678
690
|
return content[offset:]
|
|
679
691
|
return content[offset : offset + size]
|
|
680
692
|
|
|
693
|
+
def read_many(self, paths: Iterable[str]) -> dict[str, bytes]:
|
|
694
|
+
"""Read several whole files in one call to the backing state.
|
|
695
|
+
|
|
696
|
+
What ``read(path)`` returns for each path, gathered with a single
|
|
697
|
+
``get_many`` when the state offers one -- so a caller reading a
|
|
698
|
+
whole tree pays one round trip to a remote store rather than one
|
|
699
|
+
per file.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
paths: File paths to read, resolved against the current
|
|
703
|
+
working directory as ``read`` resolves them.
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
Path (as given) to its bytes, for the paths that are files. A
|
|
707
|
+
path that is not one -- missing, or a directory -- is left out
|
|
708
|
+
rather than raised on: the caller asked about many at once and
|
|
709
|
+
learns which exist from the answer.
|
|
710
|
+
"""
|
|
711
|
+
wanted: dict[str, list[str]] = {}
|
|
712
|
+
for path in paths:
|
|
713
|
+
wanted.setdefault(self._encode_path(path), []).append(path)
|
|
714
|
+
found = self._read_many(list(wanted))
|
|
715
|
+
return {path: content for key, content in found.items() for path in wanted[key]}
|
|
716
|
+
|
|
681
717
|
def write(self, path: str, content: bytes, mode: str = "w") -> None:
|
|
682
718
|
"""Write bytes to a file.
|
|
683
719
|
|
|
@@ -1070,11 +1106,12 @@ class VirtualFS:
|
|
|
1070
1106
|
if parent and not self.isdir("/" + parent):
|
|
1071
1107
|
raise FileNotFoundError(f"No such file or directory: '{path}'")
|
|
1072
1108
|
|
|
1073
|
-
# Check if already exists
|
|
1074
|
-
|
|
1075
|
-
|
|
1109
|
+
# Check if already exists (on the absolute path: ``path`` is the
|
|
1110
|
+
# key form, and checked as it is it would resolve a second time)
|
|
1111
|
+
absolute = self._absolute(normalized)
|
|
1112
|
+
if self.isfile(absolute):
|
|
1076
1113
|
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1077
|
-
if self.isdir(
|
|
1114
|
+
if self.isdir(absolute):
|
|
1078
1115
|
if exist_ok:
|
|
1079
1116
|
return
|
|
1080
1117
|
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
@@ -1106,8 +1143,8 @@ class VirtualFS:
|
|
|
1106
1143
|
"""
|
|
1107
1144
|
path = self.resolve_path(path)
|
|
1108
1145
|
|
|
1109
|
-
if not exist_ok and self.isdir(path):
|
|
1110
|
-
absolute =
|
|
1146
|
+
if not exist_ok and self.isdir(self._absolute(path)):
|
|
1147
|
+
absolute = self._absolute(path)
|
|
1111
1148
|
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1112
1149
|
|
|
1113
1150
|
parts = path.strip("/").split("/")
|
|
@@ -1133,16 +1170,19 @@ class VirtualFS:
|
|
|
1133
1170
|
"""
|
|
1134
1171
|
path = self.resolve_path(path)
|
|
1135
1172
|
normalized = self._normalize_path(path)
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1173
|
+
# Checked on the absolute path: ``path`` is the key form, and the
|
|
1174
|
+
# checks resolve their argument again.
|
|
1175
|
+
absolute = self._absolute(normalized)
|
|
1176
|
+
|
|
1177
|
+
if not self.exists(absolute):
|
|
1178
|
+
raise FileNotFoundError(f"No such directory: {absolute}")
|
|
1179
|
+
if self.isfile(absolute):
|
|
1180
|
+
raise NotADirectoryError(f"Not a directory: {absolute}")
|
|
1181
|
+
if not self.isdir(absolute):
|
|
1182
|
+
raise FileNotFoundError(f"No such directory: {absolute}")
|
|
1143
1183
|
|
|
1144
1184
|
# Check if directory is empty
|
|
1145
|
-
children = self.list(
|
|
1185
|
+
children = self.list(absolute)
|
|
1146
1186
|
if children:
|
|
1147
1187
|
raise OSError(f"Directory not empty: {path}")
|
|
1148
1188
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Tests for VirtualFS bulk operations (write_many, remove_many)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from monkeyfs import VirtualFS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBulkOperations:
|
|
9
|
+
"""Test VirtualFS bulk operations."""
|
|
10
|
+
|
|
11
|
+
def test_write_many_basic(self):
|
|
12
|
+
"""Test writing multiple files at once."""
|
|
13
|
+
vfs = VirtualFS({})
|
|
14
|
+
|
|
15
|
+
files = {
|
|
16
|
+
"file1.txt": b"content 1",
|
|
17
|
+
"file2.txt": b"content 2",
|
|
18
|
+
"dir/file3.txt": b"content 3",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
vfs.write_many(files)
|
|
22
|
+
|
|
23
|
+
# All files should exist
|
|
24
|
+
assert vfs.read("file1.txt") == b"content 1"
|
|
25
|
+
assert vfs.read("file2.txt") == b"content 2"
|
|
26
|
+
assert vfs.read("dir/file3.txt") == b"content 3"
|
|
27
|
+
|
|
28
|
+
def test_write_many_validates_all_bytes(self):
|
|
29
|
+
"""Test that write_many validates all content is bytes."""
|
|
30
|
+
vfs = VirtualFS({})
|
|
31
|
+
|
|
32
|
+
files = {
|
|
33
|
+
"file1.txt": b"content 1",
|
|
34
|
+
"file2.txt": "not bytes", # Invalid!
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
with pytest.raises(TypeError, match="Expected bytes for 'file2.txt'"):
|
|
38
|
+
vfs.write_many(files)
|
|
39
|
+
|
|
40
|
+
# No files should be written (validation happens first)
|
|
41
|
+
assert not vfs.exists("file1.txt")
|
|
42
|
+
|
|
43
|
+
def test_write_many_with_dict_state(self):
|
|
44
|
+
"""Test that write_many works with dict state (no snapshot method)."""
|
|
45
|
+
vfs = VirtualFS({})
|
|
46
|
+
|
|
47
|
+
files = {
|
|
48
|
+
"file1.txt": b"content 1",
|
|
49
|
+
"file2.txt": b"content 2",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Should not raise - dict doesn't have snapshot()
|
|
53
|
+
vfs.write_many(files)
|
|
54
|
+
|
|
55
|
+
assert vfs.exists("file1.txt")
|
|
56
|
+
assert vfs.exists("file2.txt")
|
|
57
|
+
|
|
58
|
+
def test_remove_many_basic(self):
|
|
59
|
+
"""Test removing multiple files at once."""
|
|
60
|
+
vfs = VirtualFS({})
|
|
61
|
+
|
|
62
|
+
# Create files
|
|
63
|
+
vfs.write("file1.txt", b"content 1")
|
|
64
|
+
vfs.write("file2.txt", b"content 2")
|
|
65
|
+
vfs.write("file3.txt", b"content 3")
|
|
66
|
+
|
|
67
|
+
# Remove two of them
|
|
68
|
+
vfs.remove_many(["file1.txt", "file2.txt"])
|
|
69
|
+
|
|
70
|
+
# Removed files should not exist
|
|
71
|
+
assert not vfs.exists("file1.txt")
|
|
72
|
+
assert not vfs.exists("file2.txt")
|
|
73
|
+
# Remaining file should still exist
|
|
74
|
+
assert vfs.exists("file3.txt")
|
|
75
|
+
|
|
76
|
+
def test_remove_many_missing_file_removes_preceding(self):
|
|
77
|
+
"""Test that remove_many removes files up to the missing one."""
|
|
78
|
+
vfs = VirtualFS({})
|
|
79
|
+
|
|
80
|
+
vfs.write("file1.txt", b"content 1")
|
|
81
|
+
|
|
82
|
+
with pytest.raises(FileNotFoundError):
|
|
83
|
+
vfs.remove_many(["file1.txt", "file2.txt"])
|
|
84
|
+
|
|
85
|
+
# file1.txt was removed before file2.txt failed
|
|
86
|
+
assert not vfs.exists("file1.txt")
|
|
87
|
+
|
|
88
|
+
def test_remove_many_empty_list(self):
|
|
89
|
+
"""Test that removing empty list works."""
|
|
90
|
+
vfs = VirtualFS({})
|
|
91
|
+
|
|
92
|
+
# Should not raise
|
|
93
|
+
vfs.remove_many([])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BatchingState(dict):
|
|
97
|
+
"""A dict that also answers ``get_many``, counting how it is read."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, *args, **kwargs):
|
|
100
|
+
super().__init__(*args, **kwargs)
|
|
101
|
+
self.gets: list[str] = []
|
|
102
|
+
self.batches: list[tuple[str, ...]] = []
|
|
103
|
+
|
|
104
|
+
def get(self, key, default=None):
|
|
105
|
+
self.gets.append(key)
|
|
106
|
+
return super().get(key, default)
|
|
107
|
+
|
|
108
|
+
def get_many(self, *keys):
|
|
109
|
+
self.batches.append(keys)
|
|
110
|
+
return {k: dict.__getitem__(self, k) for k in keys if k in self}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestReadMany:
|
|
114
|
+
def test_reads_every_file_in_one_call(self):
|
|
115
|
+
state = BatchingState()
|
|
116
|
+
vfs = VirtualFS(state)
|
|
117
|
+
for i in range(20):
|
|
118
|
+
vfs.write(f"/d{i % 2}/f{i}.txt", f"file {i}".encode())
|
|
119
|
+
state.gets.clear()
|
|
120
|
+
state.batches.clear()
|
|
121
|
+
|
|
122
|
+
found = vfs.read_many([f"/d{i % 2}/f{i}.txt" for i in range(20)])
|
|
123
|
+
|
|
124
|
+
assert found == {f"/d{i % 2}/f{i}.txt": f"file {i}".encode() for i in range(20)}
|
|
125
|
+
assert len(state.batches) == 1
|
|
126
|
+
assert state.gets == []
|
|
127
|
+
|
|
128
|
+
def test_leaves_out_what_is_not_a_file(self):
|
|
129
|
+
vfs = VirtualFS({})
|
|
130
|
+
vfs.write("/d/a.txt", b"a")
|
|
131
|
+
|
|
132
|
+
assert vfs.read_many(["/d/a.txt", "/d", "/nope.txt"]) == {"/d/a.txt": b"a"}
|
|
133
|
+
|
|
134
|
+
def test_answers_under_the_paths_as_given(self):
|
|
135
|
+
vfs = VirtualFS({})
|
|
136
|
+
vfs.write("/d/a.txt", b"a")
|
|
137
|
+
vfs.chdir("/d")
|
|
138
|
+
|
|
139
|
+
assert vfs.read_many(["a.txt", "/d/a.txt"]) == {
|
|
140
|
+
"a.txt": b"a",
|
|
141
|
+
"/d/a.txt": b"a",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def test_matches_read_without_get_many(self):
|
|
145
|
+
vfs = VirtualFS({})
|
|
146
|
+
vfs.write_many({"x.txt": b"x", "y/z.txt": b"z"})
|
|
147
|
+
|
|
148
|
+
assert vfs.read_many(["x.txt", "y/z.txt"]) == {
|
|
149
|
+
"x.txt": vfs.read("x.txt"),
|
|
150
|
+
"y/z.txt": vfs.read("y/z.txt"),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def test_empty(self):
|
|
154
|
+
assert VirtualFS({}).read_many([]) == {}
|
|
155
|
+
|
|
156
|
+
def test_isolated(self, tmp_path):
|
|
157
|
+
from monkeyfs import IsolatedFS
|
|
158
|
+
|
|
159
|
+
fs = IsolatedFS(str(tmp_path))
|
|
160
|
+
fs.write("a.txt", b"a")
|
|
161
|
+
fs.mkdir("d")
|
|
162
|
+
|
|
163
|
+
assert fs.read_many(["a.txt", "d", "missing.txt"]) == {"a.txt": b"a"}
|
|
164
|
+
|
|
165
|
+
def test_mount_routes_each_path_to_its_filesystem(self):
|
|
166
|
+
from monkeyfs import MountFS
|
|
167
|
+
|
|
168
|
+
base_state, mounted_state = BatchingState(), BatchingState()
|
|
169
|
+
base, mounted = VirtualFS(base_state), VirtualFS(mounted_state)
|
|
170
|
+
base.write("/top.txt", b"top")
|
|
171
|
+
mounted.write("/inner.txt", b"inner")
|
|
172
|
+
fs = MountFS(base, {"/m": mounted})
|
|
173
|
+
base_state.batches.clear()
|
|
174
|
+
mounted_state.batches.clear()
|
|
175
|
+
|
|
176
|
+
assert fs.read_many(["/top.txt", "/m/inner.txt", "/m/gone.txt"]) == {
|
|
177
|
+
"/top.txt": b"top",
|
|
178
|
+
"/m/inner.txt": b"inner",
|
|
179
|
+
}
|
|
180
|
+
assert len(base_state.batches) == 1
|
|
181
|
+
assert len(mounted_state.batches) == 1
|
|
182
|
+
|
|
183
|
+
def test_isolated_skips_what_is_not_a_regular_file(self, tmp_path):
|
|
184
|
+
import os
|
|
185
|
+
|
|
186
|
+
from monkeyfs import IsolatedFS
|
|
187
|
+
|
|
188
|
+
fs = IsolatedFS(str(tmp_path))
|
|
189
|
+
fs.write("a.txt", b"a")
|
|
190
|
+
os.mkfifo(tmp_path / "pipe") # a read would block for a writer
|
|
191
|
+
os.symlink(tmp_path / "a.txt", tmp_path / "link")
|
|
192
|
+
|
|
193
|
+
assert fs.read_many(["a.txt", "pipe", "link"]) == {
|
|
194
|
+
"a.txt": b"a",
|
|
195
|
+
"link": b"a",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def test_mount_leaves_out_a_directory_a_mount_makes(self):
|
|
199
|
+
from monkeyfs import MountFS
|
|
200
|
+
|
|
201
|
+
base = VirtualFS({})
|
|
202
|
+
base.write("/a", b"hidden by the mount below")
|
|
203
|
+
base.write("/top.txt", b"top")
|
|
204
|
+
mounted = VirtualFS({})
|
|
205
|
+
mounted.write("/c.txt", b"c")
|
|
206
|
+
fs = MountFS(base, {"/a/b": mounted})
|
|
207
|
+
|
|
208
|
+
assert not fs.isfile("/a")
|
|
209
|
+
assert fs.read_many(["/a", "/a/b", "/a/b/c.txt", "/top.txt"]) == {
|
|
210
|
+
"/a/b/c.txt": b"c",
|
|
211
|
+
"/top.txt": b"top",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def test_mount_reads_a_filesystem_without_it_file_by_file(self):
|
|
215
|
+
from monkeyfs import MountFS
|
|
216
|
+
|
|
217
|
+
class Plain:
|
|
218
|
+
"""A mounted backend with ``read`` and no ``read_many``."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, files):
|
|
221
|
+
self.files = files
|
|
222
|
+
|
|
223
|
+
def read(self, path):
|
|
224
|
+
try:
|
|
225
|
+
return self.files[path]
|
|
226
|
+
except KeyError:
|
|
227
|
+
raise FileNotFoundError(path) from None
|
|
228
|
+
|
|
229
|
+
fs = MountFS(VirtualFS({}), {"/p": Plain({"/x.txt": b"x"})})
|
|
230
|
+
|
|
231
|
+
assert fs.read_many(["/p/x.txt", "/p/y.txt"]) == {"/p/x.txt": b"x"}
|
|
232
|
+
|
|
233
|
+
def test_read_only_forwards_it(self):
|
|
234
|
+
from monkeyfs import ReadOnlyFS
|
|
235
|
+
|
|
236
|
+
vfs = VirtualFS({})
|
|
237
|
+
vfs.write("a.txt", b"a")
|
|
238
|
+
|
|
239
|
+
assert ReadOnlyFS(vfs).read_many(["a.txt"]) == {"a.txt": b"a"}
|
|
@@ -514,3 +514,63 @@ def test_isolatedfs_recursive_listing_names_are_basenames(tmp_path):
|
|
|
514
514
|
("sub", "/probe/sub"),
|
|
515
515
|
("two.txt", "/probe/sub/two.txt"),
|
|
516
516
|
]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# -- a working directory other than / (issue #30) -------------------------
|
|
520
|
+
#
|
|
521
|
+
# rmdir, mkdir and makedirs resolved a path, then handed the result (the
|
|
522
|
+
# key form, with no leading slash) to checks that resolved it again,
|
|
523
|
+
# relative to the working directory. From / the two resolutions agreed;
|
|
524
|
+
# from anywhere else they named different paths.
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@pytest.fixture(params=["/", "/workspace", "/workspace/src"])
|
|
528
|
+
def cwd_fs(request):
|
|
529
|
+
fs = VirtualFS({})
|
|
530
|
+
fs.makedirs("/workspace/src/inner", exist_ok=True)
|
|
531
|
+
fs.write("/workspace/f.txt", b"data")
|
|
532
|
+
fs.chdir(request.param)
|
|
533
|
+
return fs
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_rmdir_removes_an_empty_directory_from_any_cwd(cwd_fs):
|
|
537
|
+
cwd_fs.rmdir("/workspace/src/inner")
|
|
538
|
+
assert not cwd_fs.exists("/workspace/src/inner")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def test_rmdir_refuses_a_missing_directory_by_its_absolute_path(cwd_fs):
|
|
542
|
+
with pytest.raises(FileNotFoundError, match="/workspace/nope"):
|
|
543
|
+
cwd_fs.rmdir("/workspace/nope")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_rmdir_refuses_a_file_from_any_cwd(cwd_fs):
|
|
547
|
+
with pytest.raises(NotADirectoryError):
|
|
548
|
+
cwd_fs.rmdir("/workspace/f.txt")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def test_mkdir_refuses_an_existing_directory_from_any_cwd(cwd_fs):
|
|
552
|
+
with pytest.raises(FileExistsError):
|
|
553
|
+
cwd_fs.mkdir("/workspace/src")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def test_mkdir_refuses_an_existing_file_and_leaves_it_a_file(cwd_fs):
|
|
557
|
+
with pytest.raises(FileExistsError):
|
|
558
|
+
cwd_fs.mkdir("/workspace/f.txt")
|
|
559
|
+
assert cwd_fs.isfile("/workspace/f.txt")
|
|
560
|
+
assert not cwd_fs.isdir("/workspace/f.txt")
|
|
561
|
+
assert cwd_fs.read("/workspace/f.txt") == b"data"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_makedirs_without_exist_ok_refuses_an_existing_directory(cwd_fs):
|
|
565
|
+
with pytest.raises(FileExistsError):
|
|
566
|
+
cwd_fs.makedirs("/workspace/src", exist_ok=False)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def test_a_relative_rmdir_and_mkdir_resolve_against_the_cwd():
|
|
570
|
+
fs = VirtualFS({})
|
|
571
|
+
fs.makedirs("/workspace/src", exist_ok=True)
|
|
572
|
+
fs.chdir("/workspace")
|
|
573
|
+
fs.mkdir("new")
|
|
574
|
+
assert fs.isdir("/workspace/new")
|
|
575
|
+
fs.rmdir("src")
|
|
576
|
+
assert not fs.exists("/workspace/src")
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"""Tests for VirtualFS bulk operations (write_many, remove_many)."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from monkeyfs import VirtualFS
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestBulkOperations:
|
|
9
|
-
"""Test VirtualFS bulk operations."""
|
|
10
|
-
|
|
11
|
-
def test_write_many_basic(self):
|
|
12
|
-
"""Test writing multiple files at once."""
|
|
13
|
-
vfs = VirtualFS({})
|
|
14
|
-
|
|
15
|
-
files = {
|
|
16
|
-
"file1.txt": b"content 1",
|
|
17
|
-
"file2.txt": b"content 2",
|
|
18
|
-
"dir/file3.txt": b"content 3",
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
vfs.write_many(files)
|
|
22
|
-
|
|
23
|
-
# All files should exist
|
|
24
|
-
assert vfs.read("file1.txt") == b"content 1"
|
|
25
|
-
assert vfs.read("file2.txt") == b"content 2"
|
|
26
|
-
assert vfs.read("dir/file3.txt") == b"content 3"
|
|
27
|
-
|
|
28
|
-
def test_write_many_validates_all_bytes(self):
|
|
29
|
-
"""Test that write_many validates all content is bytes."""
|
|
30
|
-
vfs = VirtualFS({})
|
|
31
|
-
|
|
32
|
-
files = {
|
|
33
|
-
"file1.txt": b"content 1",
|
|
34
|
-
"file2.txt": "not bytes", # Invalid!
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
with pytest.raises(TypeError, match="Expected bytes for 'file2.txt'"):
|
|
38
|
-
vfs.write_many(files)
|
|
39
|
-
|
|
40
|
-
# No files should be written (validation happens first)
|
|
41
|
-
assert not vfs.exists("file1.txt")
|
|
42
|
-
|
|
43
|
-
def test_write_many_with_dict_state(self):
|
|
44
|
-
"""Test that write_many works with dict state (no snapshot method)."""
|
|
45
|
-
vfs = VirtualFS({})
|
|
46
|
-
|
|
47
|
-
files = {
|
|
48
|
-
"file1.txt": b"content 1",
|
|
49
|
-
"file2.txt": b"content 2",
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
# Should not raise - dict doesn't have snapshot()
|
|
53
|
-
vfs.write_many(files)
|
|
54
|
-
|
|
55
|
-
assert vfs.exists("file1.txt")
|
|
56
|
-
assert vfs.exists("file2.txt")
|
|
57
|
-
|
|
58
|
-
def test_remove_many_basic(self):
|
|
59
|
-
"""Test removing multiple files at once."""
|
|
60
|
-
vfs = VirtualFS({})
|
|
61
|
-
|
|
62
|
-
# Create files
|
|
63
|
-
vfs.write("file1.txt", b"content 1")
|
|
64
|
-
vfs.write("file2.txt", b"content 2")
|
|
65
|
-
vfs.write("file3.txt", b"content 3")
|
|
66
|
-
|
|
67
|
-
# Remove two of them
|
|
68
|
-
vfs.remove_many(["file1.txt", "file2.txt"])
|
|
69
|
-
|
|
70
|
-
# Removed files should not exist
|
|
71
|
-
assert not vfs.exists("file1.txt")
|
|
72
|
-
assert not vfs.exists("file2.txt")
|
|
73
|
-
# Remaining file should still exist
|
|
74
|
-
assert vfs.exists("file3.txt")
|
|
75
|
-
|
|
76
|
-
def test_remove_many_missing_file_removes_preceding(self):
|
|
77
|
-
"""Test that remove_many removes files up to the missing one."""
|
|
78
|
-
vfs = VirtualFS({})
|
|
79
|
-
|
|
80
|
-
vfs.write("file1.txt", b"content 1")
|
|
81
|
-
|
|
82
|
-
with pytest.raises(FileNotFoundError):
|
|
83
|
-
vfs.remove_many(["file1.txt", "file2.txt"])
|
|
84
|
-
|
|
85
|
-
# file1.txt was removed before file2.txt failed
|
|
86
|
-
assert not vfs.exists("file1.txt")
|
|
87
|
-
|
|
88
|
-
def test_remove_many_empty_list(self):
|
|
89
|
-
"""Test that removing empty list works."""
|
|
90
|
-
vfs = VirtualFS({})
|
|
91
|
-
|
|
92
|
-
# Should not raise
|
|
93
|
-
vfs.remove_many([])
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|