monkeyfs 0.2.0__tar.gz → 0.2.2__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.0 → monkeyfs-0.2.2}/PKG-INFO +1 -1
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/base.py +7 -3
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/conformance.py +117 -7
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/isolated.py +12 -2
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/mount.py +2 -1
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/virtual.py +30 -11
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/PKG-INFO +1 -1
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/pyproject.toml +1 -1
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_conformance.py +47 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_directory_ops.py +119 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_mount.py +23 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_open_optional.py +7 -2
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_protocol.py +70 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/LICENSE +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/README.md +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/__init__.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/context.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/__init__.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/core.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/fdtable.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/install.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/patches.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/py.typed +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/readonly.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/virtualfile.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/SOURCES.txt +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/dependency_links.txt +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/requires.txt +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/top_level.txt +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/setup.cfg +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_bulk_operations.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_expanduser.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_fd_emulation.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_finalization_and_invalidate.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_isolated.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_lazy_binary_read.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_metadata.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_patching.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_patching_pathlib.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_patching_safe_paths.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_ranged_read.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_readonly.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_symlink_entries.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_vfs_optional.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_vfs_size_limit.py +0 -0
- {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_virtual.py +0 -0
|
@@ -83,7 +83,11 @@ class FileInfo:
|
|
|
83
83
|
|
|
84
84
|
Attributes:
|
|
85
85
|
name: File or directory name (basename).
|
|
86
|
-
path:
|
|
86
|
+
path: The queried directory joined with the entry's path relative to
|
|
87
|
+
it, so list_detailed("/src", recursive=True) names
|
|
88
|
+
"/src/lib/util.py" and list_detailed("src", recursive=True) names
|
|
89
|
+
"src/lib/util.py". An absolute query therefore answers in this
|
|
90
|
+
filesystem's own absolute paths, never in a host's.
|
|
87
91
|
size: File size in bytes (0 for directories).
|
|
88
92
|
created_at: ISO 8601 timestamp when created (UTC).
|
|
89
93
|
modified_at: ISO 8601 timestamp when last modified (UTC).
|
|
@@ -266,11 +270,11 @@ class FileSystem(Protocol):
|
|
|
266
270
|
...
|
|
267
271
|
|
|
268
272
|
def mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> None:
|
|
269
|
-
"""Create a directory."""
|
|
273
|
+
"""Create a directory; raise FileExistsError unless exist_ok."""
|
|
270
274
|
...
|
|
271
275
|
|
|
272
276
|
def makedirs(self, path: str, exist_ok: bool = True) -> None:
|
|
273
|
-
"""Create directory tree."""
|
|
277
|
+
"""Create directory tree; raise FileExistsError unless exist_ok."""
|
|
274
278
|
...
|
|
275
279
|
|
|
276
280
|
def rename(self, src: str, dst: str) -> None:
|
|
@@ -8,13 +8,14 @@ nothing else -- not a test harness, not the library on the other side of the
|
|
|
8
8
|
protocol.
|
|
9
9
|
|
|
10
10
|
What it checks, in order: the required methods exist; directories,
|
|
11
|
-
``getcwd``/``chdir`` and relative resolution behave
|
|
12
|
-
back through ``stat()``, ``list()`` and ``read()``;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if it
|
|
17
|
-
it
|
|
11
|
+
``getcwd``/``chdir`` and relative resolution behave, ``exist_ok`` included;
|
|
12
|
+
a file written comes back through ``stat()``, ``list()`` and ``read()``;
|
|
13
|
+
``list_detailed()`` names each entry in the namespace it was asked in; the
|
|
14
|
+
ranged read means what it says, including the cases a backend that quietly
|
|
15
|
+
ignores ``offset`` and ``size`` would fail; ``open()`` round-trips bytes and
|
|
16
|
+
text, through the backend's own if it has one and through the synthesized one
|
|
17
|
+
under ``patch()`` if it does not; and ``rename``/``remove`` leave the
|
|
18
|
+
filesystem as they found it.
|
|
18
19
|
|
|
19
20
|
Usage::
|
|
20
21
|
|
|
@@ -76,6 +77,7 @@ def check_filesystem(fs: Any, scratch: str = SCRATCH) -> None:
|
|
|
76
77
|
path = f"{scratch}/probe.bin"
|
|
77
78
|
_write(fs, path, CONTENT)
|
|
78
79
|
_check_file_metadata(fs, scratch, path)
|
|
80
|
+
_check_listing_paths(fs, scratch)
|
|
79
81
|
_check_ranged_read(fs, path)
|
|
80
82
|
_check_open(fs, scratch, path)
|
|
81
83
|
_check_rename_and_remove(fs, scratch, path)
|
|
@@ -127,6 +129,8 @@ def _check_directories(fs: Any, scratch: str) -> None:
|
|
|
127
129
|
f"{fs.list(scratch)!r} -- names, not paths",
|
|
128
130
|
)
|
|
129
131
|
|
|
132
|
+
_check_exist_ok(fs, scratch, nested)
|
|
133
|
+
|
|
130
134
|
missing = f"{scratch}/not-there"
|
|
131
135
|
_require(
|
|
132
136
|
not fs.exists(missing) and not fs.isfile(missing) and not fs.isdir(missing),
|
|
@@ -134,6 +138,112 @@ def _check_directories(fs: Any, scratch: str) -> None:
|
|
|
134
138
|
)
|
|
135
139
|
|
|
136
140
|
|
|
141
|
+
def _check_exist_ok(fs: Any, scratch: str, nested: str) -> None:
|
|
142
|
+
"""A directory that is already there is an error unless asked to be fine.
|
|
143
|
+
|
|
144
|
+
``exist_ok`` is the only thing that makes creating an existing directory
|
|
145
|
+
succeed, and a caller reaches for False precisely to be told. A backend
|
|
146
|
+
that returns silently instead hands that caller the answer it asked to
|
|
147
|
+
be spared.
|
|
148
|
+
"""
|
|
149
|
+
fs.makedirs(scratch)
|
|
150
|
+
_require(
|
|
151
|
+
fs.isdir(scratch),
|
|
152
|
+
f"makedirs({scratch!r}) on a directory that already exists must be "
|
|
153
|
+
f"silent -- exist_ok defaults to True -- and must leave it a directory",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
cases = [
|
|
157
|
+
(
|
|
158
|
+
lambda: fs.makedirs(scratch, exist_ok=False),
|
|
159
|
+
f"makedirs({scratch!r}, exist_ok=False) on an existing directory "
|
|
160
|
+
f"must raise FileExistsError",
|
|
161
|
+
),
|
|
162
|
+
(
|
|
163
|
+
lambda: fs.mkdir(nested),
|
|
164
|
+
f"mkdir({nested!r}) on an existing directory must raise "
|
|
165
|
+
f"FileExistsError -- exist_ok defaults to False",
|
|
166
|
+
),
|
|
167
|
+
(
|
|
168
|
+
lambda: fs.mkdir(nested, parents=True, exist_ok=False),
|
|
169
|
+
f"mkdir({nested!r}, parents=True, exist_ok=False) on an existing "
|
|
170
|
+
f"directory must raise FileExistsError: parents says how to build "
|
|
171
|
+
f"the tree, not whether the end of it may already be there",
|
|
172
|
+
),
|
|
173
|
+
]
|
|
174
|
+
for call, message in cases:
|
|
175
|
+
try:
|
|
176
|
+
call()
|
|
177
|
+
except FileExistsError:
|
|
178
|
+
continue
|
|
179
|
+
except Exception as error: # noqa: BLE001 - reported as the failure
|
|
180
|
+
raise AssertionError(
|
|
181
|
+
f"{message}, not {type(error).__name__} ({error})"
|
|
182
|
+
) from error
|
|
183
|
+
raise AssertionError(f"{message}, rather than returning silently")
|
|
184
|
+
|
|
185
|
+
fs.mkdir(nested, exist_ok=True)
|
|
186
|
+
_require(
|
|
187
|
+
fs.isdir(nested),
|
|
188
|
+
f"mkdir({nested!r}, exist_ok=True) on an existing directory must be "
|
|
189
|
+
f"silent and must leave it a directory",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _check_listing_paths(fs: Any, scratch: str) -> None:
|
|
194
|
+
"""``FileInfo.path``: the directory as queried, joined with the entry.
|
|
195
|
+
|
|
196
|
+
Listing ``"/src"`` names ``"/src/lib/util.py"`` and listing ``"src"``
|
|
197
|
+
names ``"src/lib/util.py"``, so a path that comes out of a listing goes
|
|
198
|
+
back in, and an absolute query never answers in anything but this
|
|
199
|
+
filesystem's own absolute paths.
|
|
200
|
+
"""
|
|
201
|
+
if not _has(fs, "list_detailed"):
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
leaf = f"{scratch}/nested/leaf.bin"
|
|
205
|
+
_write(fs, leaf, b"leaf")
|
|
206
|
+
try:
|
|
207
|
+
absolute = {info.name: info.path for info in fs.list_detailed(scratch)}
|
|
208
|
+
_require(
|
|
209
|
+
absolute.get("probe.bin") == f"{scratch}/probe.bin",
|
|
210
|
+
f"list_detailed({scratch!r})[...].path must be the queried "
|
|
211
|
+
f"directory joined with the entry, {scratch + '/probe.bin'!r}, got "
|
|
212
|
+
f"{absolute.get('probe.bin')!r}",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
entries = fs.list_detailed(scratch, True)
|
|
216
|
+
deep = [info.path for info in entries]
|
|
217
|
+
_require(
|
|
218
|
+
f"{scratch}/nested/leaf.bin" in deep,
|
|
219
|
+
f"a recursive list_detailed({scratch!r}) must name a nested entry "
|
|
220
|
+
f"{scratch + '/nested/leaf.bin'!r}, got {deep!r}",
|
|
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
|
+
)
|
|
230
|
+
|
|
231
|
+
start = fs.getcwd()
|
|
232
|
+
fs.chdir(scratch)
|
|
233
|
+
try:
|
|
234
|
+
relative = [info.path for info in fs.list_detailed("nested")]
|
|
235
|
+
_require(
|
|
236
|
+
relative == ["nested/leaf.bin"],
|
|
237
|
+
f"list_detailed('nested') must answer in the namespace it was "
|
|
238
|
+
f"asked in: a relative query gives paths relative to the same "
|
|
239
|
+
f"place, ['nested/leaf.bin'], got {relative!r}",
|
|
240
|
+
)
|
|
241
|
+
finally:
|
|
242
|
+
fs.chdir(start)
|
|
243
|
+
finally:
|
|
244
|
+
fs.remove(leaf)
|
|
245
|
+
|
|
246
|
+
|
|
137
247
|
def _check_working_directory(fs: Any, scratch: str) -> None:
|
|
138
248
|
start = fs.getcwd()
|
|
139
249
|
_require(
|
|
@@ -570,6 +570,14 @@ class IsolatedFS:
|
|
|
570
570
|
def list_detailed(self, path: str = ".", recursive: bool = False) -> list[FileInfo]:
|
|
571
571
|
"""List directory with detailed file information.
|
|
572
572
|
|
|
573
|
+
Each ``FileInfo.path`` is the directory as it was asked for, joined
|
|
574
|
+
with the entry's path relative to it: listing ``"/src"`` names
|
|
575
|
+
``"/src/lib/util.py"`` and listing ``"src"`` names ``"src/lib/util.py"``.
|
|
576
|
+
So an absolute query answers in absolute paths and a relative one
|
|
577
|
+
answers relative to the same place the caller named, and neither ever
|
|
578
|
+
carries the host location of the root -- what comes back is a path
|
|
579
|
+
this filesystem accepts back.
|
|
580
|
+
|
|
573
581
|
Args:
|
|
574
582
|
path: Directory path to list.
|
|
575
583
|
recursive: If True, list all nested items.
|
|
@@ -582,15 +590,17 @@ class IsolatedFS:
|
|
|
582
590
|
|
|
583
591
|
result = []
|
|
584
592
|
items = resolved.rglob("*") if recursive else resolved.iterdir()
|
|
593
|
+
prefix = path.rstrip("/")
|
|
585
594
|
|
|
586
595
|
for item in items:
|
|
587
|
-
|
|
596
|
+
relative = item.relative_to(resolved).as_posix()
|
|
597
|
+
display = f"{prefix}/{relative}" if prefix != "." else relative
|
|
588
598
|
stat_info = item.stat()
|
|
589
599
|
|
|
590
600
|
result.append(
|
|
591
601
|
FileInfo(
|
|
592
602
|
name=item.name,
|
|
593
|
-
path=
|
|
603
|
+
path=display,
|
|
594
604
|
is_dir=item.is_dir(),
|
|
595
605
|
size=stat_info.st_size if item.is_file() else 0,
|
|
596
606
|
created_at=datetime.fromtimestamp(
|
|
@@ -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=
|
|
292
|
+
name=basename,
|
|
292
293
|
path=display,
|
|
293
294
|
size=meta.size,
|
|
294
295
|
created_at=meta.created_at,
|
|
@@ -1027,7 +1027,10 @@ class VirtualFS:
|
|
|
1027
1027
|
parents: If True, create parent directories as needed.
|
|
1028
1028
|
|
|
1029
1029
|
Raises:
|
|
1030
|
-
FileExistsError: If path exists
|
|
1030
|
+
FileExistsError: If path exists, as a file or -- unless exist_ok
|
|
1031
|
+
is True -- as a directory. Any path isdir() reports True for
|
|
1032
|
+
exists, including a directory that is there only because a
|
|
1033
|
+
file path passes through it.
|
|
1031
1034
|
FileNotFoundError: If parent doesn't exist and parents=False.
|
|
1032
1035
|
"""
|
|
1033
1036
|
if parents:
|
|
@@ -1043,12 +1046,13 @@ class VirtualFS:
|
|
|
1043
1046
|
raise FileNotFoundError(f"No such file or directory: '{path}'")
|
|
1044
1047
|
|
|
1045
1048
|
# Check if already exists
|
|
1049
|
+
absolute = "/" + normalized
|
|
1046
1050
|
if self.isfile(path):
|
|
1047
|
-
raise FileExistsError(
|
|
1051
|
+
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1048
1052
|
if self.isdir(path):
|
|
1049
1053
|
if exist_ok:
|
|
1050
1054
|
return
|
|
1051
|
-
raise FileExistsError(
|
|
1055
|
+
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1052
1056
|
|
|
1053
1057
|
# Create the directory's own row (implicit directories have none)
|
|
1054
1058
|
now = datetime.now(timezone.utc).isoformat()
|
|
@@ -1061,23 +1065,33 @@ class VirtualFS:
|
|
|
1061
1065
|
def makedirs(self, path: str, exist_ok: bool = True) -> None:
|
|
1062
1066
|
"""Create directory tree.
|
|
1063
1067
|
|
|
1064
|
-
Creates all parent directories as needed.
|
|
1068
|
+
Creates all parent directories as needed. With ``exist_ok`` False the
|
|
1069
|
+
directory must not already be there: anything ``isdir()`` reports True
|
|
1070
|
+
for counts as there, whether it was created by ``mkdir()`` or exists
|
|
1071
|
+
only because a file path passes through it. A caller cannot tell the
|
|
1072
|
+
two apart through this interface, so neither does the check.
|
|
1065
1073
|
|
|
1066
1074
|
Args:
|
|
1067
1075
|
path: Directory path.
|
|
1068
1076
|
exist_ok: If True, don't raise if directory exists.
|
|
1069
1077
|
|
|
1070
1078
|
Raises:
|
|
1071
|
-
FileExistsError: If path exists as a file
|
|
1079
|
+
FileExistsError: If path exists as a file, or if it exists as a
|
|
1080
|
+
directory and exist_ok is False.
|
|
1072
1081
|
"""
|
|
1073
1082
|
path = self.resolve_path(path)
|
|
1083
|
+
|
|
1084
|
+
if not exist_ok and self.isdir(path):
|
|
1085
|
+
absolute = "/" + path.strip("/")
|
|
1086
|
+
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1087
|
+
|
|
1074
1088
|
parts = path.strip("/").split("/")
|
|
1075
1089
|
|
|
1076
1090
|
# Create each parent directory
|
|
1077
1091
|
for i in range(len(parts)):
|
|
1078
1092
|
dir_path = "/" + "/".join(parts[: i + 1])
|
|
1079
1093
|
if self.isfile(dir_path):
|
|
1080
|
-
raise FileExistsError(
|
|
1094
|
+
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), dir_path)
|
|
1081
1095
|
if not self.isdir(dir_path):
|
|
1082
1096
|
self.mkdir(dir_path, exist_ok=True)
|
|
1083
1097
|
|
|
@@ -1311,7 +1325,10 @@ class VirtualFS:
|
|
|
1311
1325
|
recursive: If True, list all nested files and directories.
|
|
1312
1326
|
|
|
1313
1327
|
Returns:
|
|
1314
|
-
List of FileInfo objects sorted by
|
|
1328
|
+
List of FileInfo objects sorted by path relative to the queried
|
|
1329
|
+
directory, so a directory precedes its contents. ``name`` is the
|
|
1330
|
+
entry's own name, the last component of ``path``, at every depth;
|
|
1331
|
+
it is not what the list is ordered by.
|
|
1315
1332
|
|
|
1316
1333
|
Example:
|
|
1317
1334
|
>>> files = vfs.list_detailed("/shared")
|
|
@@ -1336,7 +1353,9 @@ class VirtualFS:
|
|
|
1336
1353
|
internal_path = f"{base}/{name}" if base else name
|
|
1337
1354
|
absolute = "/" + internal_path
|
|
1338
1355
|
|
|
1339
|
-
# Display path preserves the user's queried prefix
|
|
1356
|
+
# Display path preserves the user's queried prefix; the name
|
|
1357
|
+
# is the last component alone, whatever depth the entry sits at.
|
|
1358
|
+
basename = name.rsplit("/", 1)[-1]
|
|
1340
1359
|
display = f"{user_prefix}/{name}" if user_prefix != "." else name
|
|
1341
1360
|
|
|
1342
1361
|
meta = self._meta_at(internal_path)
|
|
@@ -1346,7 +1365,7 @@ class VirtualFS:
|
|
|
1346
1365
|
now = self._now_iso()
|
|
1347
1366
|
result.append(
|
|
1348
1367
|
FileInfo(
|
|
1349
|
-
name=
|
|
1368
|
+
name=basename,
|
|
1350
1369
|
path=display,
|
|
1351
1370
|
size=0,
|
|
1352
1371
|
created_at=meta.created_at if meta is not None else now,
|
|
@@ -1357,7 +1376,7 @@ class VirtualFS:
|
|
|
1357
1376
|
elif meta is not None:
|
|
1358
1377
|
result.append(
|
|
1359
1378
|
FileInfo(
|
|
1360
|
-
name=
|
|
1379
|
+
name=basename,
|
|
1361
1380
|
path=display,
|
|
1362
1381
|
size=meta.size,
|
|
1363
1382
|
created_at=meta.created_at,
|
|
@@ -1371,7 +1390,7 @@ class VirtualFS:
|
|
|
1371
1390
|
now = self._now_iso()
|
|
1372
1391
|
result.append(
|
|
1373
1392
|
FileInfo(
|
|
1374
|
-
name=
|
|
1393
|
+
name=basename,
|
|
1375
1394
|
path=display,
|
|
1376
1395
|
size=len(content),
|
|
1377
1396
|
created_at=now,
|
|
@@ -105,6 +105,53 @@ class TestBackendsThatShouldFail:
|
|
|
105
105
|
with pytest.raises(AssertionError, match="FileNotFoundError"):
|
|
106
106
|
check_filesystem(ForgivingRemove({}))
|
|
107
107
|
|
|
108
|
+
def test_a_makedirs_that_forgives_an_existing_directory(self):
|
|
109
|
+
class ForgivingMakedirs(VirtualFS):
|
|
110
|
+
def makedirs(self, path, exist_ok=True):
|
|
111
|
+
super().makedirs(path, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
with pytest.raises(AssertionError, match="exist_ok=False"):
|
|
114
|
+
check_filesystem(ForgivingMakedirs({}))
|
|
115
|
+
|
|
116
|
+
def test_a_mkdir_that_forgives_an_existing_directory(self):
|
|
117
|
+
class ForgivingMkdir(VirtualFS):
|
|
118
|
+
def mkdir(self, path, parents=False, exist_ok=False):
|
|
119
|
+
super().mkdir(path, parents=parents, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
with pytest.raises(AssertionError, match="FileExistsError"):
|
|
122
|
+
check_filesystem(ForgivingMkdir({}))
|
|
123
|
+
|
|
124
|
+
def test_a_mkdir_that_forgives_when_building_parents(self):
|
|
125
|
+
class ForgivingParents(VirtualFS):
|
|
126
|
+
def mkdir(self, path, parents=False, exist_ok=False):
|
|
127
|
+
super().mkdir(path, parents=parents, exist_ok=exist_ok or parents)
|
|
128
|
+
|
|
129
|
+
with pytest.raises(AssertionError, match="parents"):
|
|
130
|
+
check_filesystem(ForgivingParents({}))
|
|
131
|
+
|
|
132
|
+
def test_a_listing_that_answers_in_another_namespace(self):
|
|
133
|
+
class RootRelative(VirtualFS):
|
|
134
|
+
def list_detailed(self, path=".", recursive=False):
|
|
135
|
+
entries = super().list_detailed(path, recursive)
|
|
136
|
+
for info in entries:
|
|
137
|
+
info.path = info.path.lstrip("/")
|
|
138
|
+
return entries
|
|
139
|
+
|
|
140
|
+
with pytest.raises(AssertionError, match="list_detailed"):
|
|
141
|
+
check_filesystem(RootRelative({}))
|
|
142
|
+
|
|
143
|
+
def test_a_listing_that_answers_absolutely_whatever_it_was_asked(self):
|
|
144
|
+
class AlwaysAbsolute(VirtualFS):
|
|
145
|
+
def list_detailed(self, path=".", recursive=False):
|
|
146
|
+
entries = super().list_detailed(path, recursive)
|
|
147
|
+
for info in entries:
|
|
148
|
+
if not info.path.startswith("/"):
|
|
149
|
+
info.path = "/" + self.resolve_path(info.path).lstrip("/")
|
|
150
|
+
return entries
|
|
151
|
+
|
|
152
|
+
with pytest.raises(AssertionError, match="relative query"):
|
|
153
|
+
check_filesystem(AlwaysAbsolute({}))
|
|
154
|
+
|
|
108
155
|
def test_a_filesystem_that_is_not_empty(self):
|
|
109
156
|
fs = VirtualFS({})
|
|
110
157
|
fs.makedirs(SCRATCH)
|
|
@@ -4,6 +4,7 @@ These tests verify the explicit directory support and current working directory
|
|
|
4
4
|
functionality added to VirtualFS.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import errno
|
|
7
8
|
import os
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
@@ -103,6 +104,65 @@ class TestVFSMakedirs:
|
|
|
103
104
|
with pytest.raises(FileExistsError):
|
|
104
105
|
vfs.makedirs("a/b/c")
|
|
105
106
|
|
|
107
|
+
def test_makedirs_exist_ok_false_raises_on_an_existing_directory(self):
|
|
108
|
+
"""An existing directory is an error unless exist_ok says otherwise."""
|
|
109
|
+
vfs = VirtualFS({})
|
|
110
|
+
vfs.mkdir("d")
|
|
111
|
+
|
|
112
|
+
with pytest.raises(FileExistsError) as caught:
|
|
113
|
+
vfs.makedirs("d", exist_ok=False)
|
|
114
|
+
|
|
115
|
+
assert caught.value.errno == errno.EEXIST
|
|
116
|
+
|
|
117
|
+
def test_makedirs_exist_ok_false_raises_on_an_implicit_directory(self):
|
|
118
|
+
"""A directory a file path passes through exists like any other."""
|
|
119
|
+
vfs = VirtualFS({})
|
|
120
|
+
vfs.write("d/file.txt", b"content")
|
|
121
|
+
|
|
122
|
+
assert vfs.isdir("d") is True
|
|
123
|
+
with pytest.raises(FileExistsError) as caught:
|
|
124
|
+
vfs.makedirs("d", exist_ok=False)
|
|
125
|
+
|
|
126
|
+
assert caught.value.errno == errno.EEXIST
|
|
127
|
+
|
|
128
|
+
def test_makedirs_exist_ok_true_is_silent(self):
|
|
129
|
+
"""The default keeps working on a tree that is already there."""
|
|
130
|
+
vfs = VirtualFS({})
|
|
131
|
+
vfs.makedirs("a/b/c")
|
|
132
|
+
|
|
133
|
+
vfs.makedirs("a/b/c")
|
|
134
|
+
vfs.makedirs("a/b/c", exist_ok=True)
|
|
135
|
+
|
|
136
|
+
assert vfs.isdir("a/b/c") is True
|
|
137
|
+
|
|
138
|
+
def test_makedirs_exist_ok_false_still_creates_a_missing_tree(self):
|
|
139
|
+
"""Raising on what exists must not stop it creating what does not."""
|
|
140
|
+
vfs = VirtualFS({})
|
|
141
|
+
vfs.mkdir("a")
|
|
142
|
+
|
|
143
|
+
vfs.makedirs("a/b/c", exist_ok=False)
|
|
144
|
+
|
|
145
|
+
assert vfs.isdir("a/b") is True
|
|
146
|
+
assert vfs.isdir("a/b/c") is True
|
|
147
|
+
|
|
148
|
+
def test_mkdir_parents_exist_ok_false_raises_on_an_existing_directory(self):
|
|
149
|
+
"""parents=True must not soften the check exist_ok=False asks for."""
|
|
150
|
+
vfs = VirtualFS({})
|
|
151
|
+
vfs.makedirs("a/b")
|
|
152
|
+
|
|
153
|
+
with pytest.raises(FileExistsError) as caught:
|
|
154
|
+
vfs.mkdir("a/b", parents=True, exist_ok=False)
|
|
155
|
+
|
|
156
|
+
assert caught.value.errno == errno.EEXIST
|
|
157
|
+
|
|
158
|
+
def test_mkdir_parents_exist_ok_true_is_silent(self):
|
|
159
|
+
vfs = VirtualFS({})
|
|
160
|
+
vfs.makedirs("a/b")
|
|
161
|
+
|
|
162
|
+
vfs.mkdir("a/b", parents=True, exist_ok=True)
|
|
163
|
+
|
|
164
|
+
assert vfs.isdir("a/b") is True
|
|
165
|
+
|
|
106
166
|
|
|
107
167
|
class TestVFSRmdir:
|
|
108
168
|
"""Test VirtualFS.rmdir() functionality."""
|
|
@@ -311,6 +371,17 @@ class TestVFSOsPatches:
|
|
|
311
371
|
assert os.path.isdir("a/b") is True
|
|
312
372
|
assert os.path.isdir("a/b/c") is True
|
|
313
373
|
|
|
374
|
+
def test_os_makedirs_defaults_to_refusing_an_existing_directory(self):
|
|
375
|
+
"""os.makedirs() raises on a directory that is already there."""
|
|
376
|
+
vfs = VirtualFS({})
|
|
377
|
+
vfs.mkdir("a")
|
|
378
|
+
|
|
379
|
+
with patch(vfs):
|
|
380
|
+
with pytest.raises(FileExistsError):
|
|
381
|
+
os.makedirs("a")
|
|
382
|
+
|
|
383
|
+
os.makedirs("a", exist_ok=True)
|
|
384
|
+
|
|
314
385
|
def test_os_path_abspath_respects_cwd(self):
|
|
315
386
|
"""os.path.abspath resolves against virtual CWD."""
|
|
316
387
|
vfs = VirtualFS({})
|
|
@@ -395,3 +466,51 @@ class TestImplicitDirectories:
|
|
|
395
466
|
assert vfs.list("/") == ["pkg", "top.py"]
|
|
396
467
|
assert vfs.list("pkg") == ["__init__.py", "sub", "utils.py"]
|
|
397
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
|
+
]
|
|
@@ -564,6 +564,29 @@ class TestNestedMountListing:
|
|
|
564
564
|
paths = [e.path for e in fs.list_detailed("/", recursive=True)]
|
|
565
565
|
assert "/workspace/data/out/o.txt" in paths
|
|
566
566
|
|
|
567
|
+
def test_list_detailed_over_an_isolated_mount_stays_in_the_namespace(
|
|
568
|
+
self, tmp_path
|
|
569
|
+
):
|
|
570
|
+
"""The mount prefix goes on once, and the sandbox root never appears."""
|
|
571
|
+
inner = IsolatedFS(str(tmp_path))
|
|
572
|
+
inner.write("sub/one.txt", b"x")
|
|
573
|
+
fs = MountFS(VirtualFS({}), {"/data": inner})
|
|
574
|
+
fs.write("/top.txt", b"t")
|
|
575
|
+
|
|
576
|
+
assert [e.path for e in fs.list_detailed("/")] == ["/data", "/top.txt"]
|
|
577
|
+
assert [e.path for e in fs.list_detailed("/data", recursive=True)] == [
|
|
578
|
+
"/data/sub",
|
|
579
|
+
"/data/sub/one.txt",
|
|
580
|
+
]
|
|
581
|
+
for entry in fs.list_detailed("/", recursive=True):
|
|
582
|
+
assert not entry.path.startswith("//")
|
|
583
|
+
assert str(tmp_path) not in entry.path
|
|
584
|
+
if not entry.is_dir:
|
|
585
|
+
assert fs.read(entry.path)
|
|
586
|
+
|
|
587
|
+
fs.chdir("/data")
|
|
588
|
+
assert [e.path for e in fs.list_detailed("sub")] == ["sub/one.txt"]
|
|
589
|
+
|
|
567
590
|
def test_an_implicit_parent_inside_a_mount_lists_like_one_in_the_base(self):
|
|
568
591
|
outer = VirtualFS()
|
|
569
592
|
deep = VirtualFS()
|
|
@@ -180,13 +180,18 @@ class TermishFS:
|
|
|
180
180
|
def list_detailed(self, path: str = ".", recursive: bool = False) -> list[FileInfo]:
|
|
181
181
|
entries = []
|
|
182
182
|
base = self._abs(path)
|
|
183
|
+
prefix = path.rstrip("/")
|
|
183
184
|
for name in self.list(path, recursive):
|
|
184
185
|
full = posixpath.join(base, name)
|
|
186
|
+
# The queried directory joined with the entry: an absolute query
|
|
187
|
+
# answers absolute, a relative one answers relative to the same
|
|
188
|
+
# place the caller named.
|
|
189
|
+
display = f"{prefix}/{name}" if prefix != "." else name
|
|
185
190
|
meta = self.stat(full)
|
|
186
191
|
entries.append(
|
|
187
192
|
FileInfo(
|
|
188
|
-
name=name,
|
|
189
|
-
path=
|
|
193
|
+
name=posixpath.basename(name),
|
|
194
|
+
path=display,
|
|
190
195
|
size=meta.size,
|
|
191
196
|
created_at=meta.created_at,
|
|
192
197
|
modified_at=meta.modified_at,
|
|
@@ -261,6 +261,76 @@ class TestMountFSForwarding:
|
|
|
261
261
|
mount.invalidate() # must not raise
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
class TestListingPathsAgreeAcrossBackends:
|
|
265
|
+
"""``FileInfo.path`` is the queried directory joined with the entry.
|
|
266
|
+
|
|
267
|
+
A listing answers in the namespace it was asked in: an absolute query in
|
|
268
|
+
absolute paths, a relative one relative to the place the caller named.
|
|
269
|
+
Every backend answers the same, because the path is the caller's to use
|
|
270
|
+
-- to read, to pass back in, to show -- and a backend that answers in its
|
|
271
|
+
own storage layout instead hands back something that names the file to
|
|
272
|
+
nobody but itself.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
BACKENDS = {
|
|
276
|
+
"virtual": lambda tmp: VirtualFS({}),
|
|
277
|
+
"isolated": lambda tmp: IsolatedFS(str(tmp)),
|
|
278
|
+
"mounted": lambda tmp: MountFS(VirtualFS({})),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@pytest.fixture(params=sorted(BACKENDS))
|
|
282
|
+
def fs(self, request, tmp_path):
|
|
283
|
+
filesystem = self.BACKENDS[request.param](tmp_path)
|
|
284
|
+
filesystem.makedirs("/probe/sub/deep")
|
|
285
|
+
filesystem.write("/probe/sub/deep/three.txt", b"three")
|
|
286
|
+
filesystem.write("/probe/sub/one.txt", b"one")
|
|
287
|
+
filesystem.write("/probe/two.txt", b"two")
|
|
288
|
+
return filesystem
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def paths(fs, path, recursive=False):
|
|
292
|
+
return sorted(info.path for info in fs.list_detailed(path, recursive))
|
|
293
|
+
|
|
294
|
+
def test_absolute_query(self, fs):
|
|
295
|
+
assert self.paths(fs, "/probe") == ["/probe/sub", "/probe/two.txt"]
|
|
296
|
+
|
|
297
|
+
def test_absolute_query_recursive(self, fs):
|
|
298
|
+
assert self.paths(fs, "/probe", True) == [
|
|
299
|
+
"/probe/sub",
|
|
300
|
+
"/probe/sub/deep",
|
|
301
|
+
"/probe/sub/deep/three.txt",
|
|
302
|
+
"/probe/sub/one.txt",
|
|
303
|
+
"/probe/two.txt",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
def test_root_query(self, fs):
|
|
307
|
+
assert self.paths(fs, "/") == ["/probe"]
|
|
308
|
+
|
|
309
|
+
def test_a_trailing_slash_does_not_double(self, fs):
|
|
310
|
+
assert self.paths(fs, "/probe/") == ["/probe/sub", "/probe/two.txt"]
|
|
311
|
+
|
|
312
|
+
def test_relative_query(self, fs):
|
|
313
|
+
fs.chdir("/probe")
|
|
314
|
+
assert self.paths(fs, "sub") == ["sub/deep", "sub/one.txt"]
|
|
315
|
+
|
|
316
|
+
def test_relative_query_recursive(self, fs):
|
|
317
|
+
fs.chdir("/probe")
|
|
318
|
+
assert self.paths(fs, "sub", True) == [
|
|
319
|
+
"sub/deep",
|
|
320
|
+
"sub/deep/three.txt",
|
|
321
|
+
"sub/one.txt",
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
def test_the_cwd_itself_is_named_by_its_children(self, fs):
|
|
325
|
+
fs.chdir("/probe")
|
|
326
|
+
assert self.paths(fs, ".") == ["sub", "two.txt"]
|
|
327
|
+
|
|
328
|
+
def test_a_listed_path_reads_back(self, fs):
|
|
329
|
+
for info in fs.list_detailed("/probe", True):
|
|
330
|
+
if not info.is_dir:
|
|
331
|
+
assert fs.exists(info.path), info.path
|
|
332
|
+
|
|
333
|
+
|
|
264
334
|
class TestReadOnlyFSForwarding:
|
|
265
335
|
"""The wrapper's allowlist comes from the protocol, so it cannot drift."""
|
|
266
336
|
|
|
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
|