monkeyfs 0.2.3__tar.gz → 0.2.4__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.4}/PKG-INFO +1 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/base.py +8 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/isolated.py +17 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/mount.py +38 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/virtual.py +25 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs.egg-info/PKG-INFO +1 -1
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/pyproject.toml +1 -1
- monkeyfs-0.2.4/tests/test_bulk_operations.py +239 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_readonly.py +1 -0
- monkeyfs-0.2.3/tests/test_bulk_operations.py +0 -93
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/LICENSE +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/README.md +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/__init__.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/conformance.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/context.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/patching/__init__.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/patching/core.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/patching/fdtable.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/patching/install.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/patching/patches.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/py.typed +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/readonly.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs/virtualfile.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs.egg-info/SOURCES.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs.egg-info/dependency_links.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs.egg-info/requires.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/monkeyfs.egg-info/top_level.txt +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/setup.cfg +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_conformance.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_directory_ops.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_expanduser.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_fd_emulation.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_finalization_and_invalidate.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_isolated.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_lazy_binary_read.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_metadata.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_mount.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_open_optional.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_patching.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_patching_pathlib.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_patching_safe_paths.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_protocol.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_ranged_read.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_symlink_entries.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_vfs_optional.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/tests/test_vfs_size_limit.py +0 -0
- {monkeyfs-0.2.3 → monkeyfs-0.2.4}/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
|
|
|
@@ -678,6 +678,30 @@ class VirtualFS:
|
|
|
678
678
|
return content[offset:]
|
|
679
679
|
return content[offset : offset + size]
|
|
680
680
|
|
|
681
|
+
def read_many(self, paths: Iterable[str]) -> dict[str, bytes]:
|
|
682
|
+
"""Read several whole files in one call to the backing state.
|
|
683
|
+
|
|
684
|
+
What ``read(path)`` returns for each path, gathered with a single
|
|
685
|
+
``get_many`` when the state offers one -- so a caller reading a
|
|
686
|
+
whole tree pays one round trip to a remote store rather than one
|
|
687
|
+
per file.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
paths: File paths to read, resolved against the current
|
|
691
|
+
working directory as ``read`` resolves them.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Path (as given) to its bytes, for the paths that are files. A
|
|
695
|
+
path that is not one -- missing, or a directory -- is left out
|
|
696
|
+
rather than raised on: the caller asked about many at once and
|
|
697
|
+
learns which exist from the answer.
|
|
698
|
+
"""
|
|
699
|
+
wanted: dict[str, list[str]] = {}
|
|
700
|
+
for path in paths:
|
|
701
|
+
wanted.setdefault(self._encode_path(path), []).append(path)
|
|
702
|
+
found = self._read_many(list(wanted))
|
|
703
|
+
return {path: content for key, content in found.items() for path in wanted[key]}
|
|
704
|
+
|
|
681
705
|
def write(self, path: str, content: bytes, mode: str = "w") -> None:
|
|
682
706
|
"""Write bytes to a file.
|
|
683
707
|
|
|
@@ -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"}
|
|
@@ -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
|
|
File without changes
|