monkeyfs 0.2.4__tar.gz → 0.2.6__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.4 → monkeyfs-0.2.6}/PKG-INFO +1 -1
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/mount.py +120 -30
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/virtual.py +30 -14
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/PKG-INFO +1 -1
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/pyproject.toml +1 -1
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_directory_ops.py +60 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_mount.py +146 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/LICENSE +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/README.md +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/__init__.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/base.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/conformance.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/context.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/isolated.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/__init__.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/core.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/fdtable.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/install.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/patches.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/py.typed +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/readonly.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/virtualfile.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/SOURCES.txt +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/dependency_links.txt +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/requires.txt +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/top_level.txt +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/setup.cfg +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_bulk_operations.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_conformance.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_expanduser.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_fd_emulation.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_finalization_and_invalidate.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_isolated.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_lazy_binary_read.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_metadata.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_open_optional.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_patching.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_patching_pathlib.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_patching_safe_paths.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_protocol.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_ranged_read.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_readonly.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_symlink_entries.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_vfs_optional.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_vfs_size_limit.py +0 -0
- {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_virtual.py +0 -0
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import errno
|
|
11
11
|
import os
|
|
12
|
-
|
|
12
|
+
import posixpath
|
|
13
|
+
from collections.abc import Iterable, Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
13
15
|
from datetime import datetime, timezone
|
|
14
16
|
from typing import Any
|
|
15
17
|
|
|
@@ -128,6 +130,68 @@ class MountFS:
|
|
|
128
130
|
base_path = abs_path.lstrip("/") or "/"
|
|
129
131
|
return self._base, base_path
|
|
130
132
|
|
|
133
|
+
def _is_implied_dir(self, abs_path: str) -> bool:
|
|
134
|
+
"""Whether abs_path is a directory on the way to a mount prefix.
|
|
135
|
+
|
|
136
|
+
The composition shows every such directory, whether the
|
|
137
|
+
filesystem that owns its path holds it or not -- ``isdir`` says
|
|
138
|
+
yes to ``/workspace`` with only ``/workspace/data`` mounted. A
|
|
139
|
+
mount prefix is not one (its filesystem's root answers for it),
|
|
140
|
+
and neither is ``/``.
|
|
141
|
+
"""
|
|
142
|
+
return (
|
|
143
|
+
abs_path != "/"
|
|
144
|
+
and abs_path not in self._mounts
|
|
145
|
+
and self._is_mount_point(abs_path)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def _implied_parents(self, *paths: str) -> Iterator[None]:
|
|
150
|
+
"""Hold the implied directories that creates at ``paths`` land in.
|
|
151
|
+
|
|
152
|
+
A file, directory or link created in an implied directory lands
|
|
153
|
+
in the filesystem that owns the directory's path, which may not
|
|
154
|
+
hold it. That filesystem is given the directory first, as the
|
|
155
|
+
namespace already promised it. Only an implied directory is
|
|
156
|
+
made: a missing one that leads to no mount stays missing, and
|
|
157
|
+
the create fails there as it would without the composition.
|
|
158
|
+
|
|
159
|
+
If the create raises, the directories made for it are removed
|
|
160
|
+
again, deepest first, so a failed call leaves the filesystem
|
|
161
|
+
underneath as it found it -- on a versioned base, that is the
|
|
162
|
+
difference between a failed call and a staged change. One that
|
|
163
|
+
is no longer empty by then stays: something did land in it. Only
|
|
164
|
+
a failure the call itself raises is seen; a handle that defers
|
|
165
|
+
its write to ``close()`` has been handed back by then, over a
|
|
166
|
+
path that exists.
|
|
167
|
+
"""
|
|
168
|
+
missing: list[str] = []
|
|
169
|
+
for path in paths:
|
|
170
|
+
abs_path = self._to_absolute(path)
|
|
171
|
+
if abs_path in self._mounts:
|
|
172
|
+
continue
|
|
173
|
+
probe = posixpath.dirname(abs_path)
|
|
174
|
+
while self._is_implied_dir(probe) and probe not in missing:
|
|
175
|
+
fs, inner = self._resolve(probe)
|
|
176
|
+
if fs.isdir(inner):
|
|
177
|
+
break
|
|
178
|
+
missing.append(probe)
|
|
179
|
+
probe = posixpath.dirname(probe)
|
|
180
|
+
made_from_deepest = sorted(missing, key=lambda p: p.count("/"), reverse=True)
|
|
181
|
+
try:
|
|
182
|
+
for abs_dir in made_from_deepest:
|
|
183
|
+
fs, inner = self._resolve(abs_dir)
|
|
184
|
+
fs.makedirs(inner, exist_ok=True)
|
|
185
|
+
yield
|
|
186
|
+
except BaseException:
|
|
187
|
+
for abs_dir in made_from_deepest:
|
|
188
|
+
fs, inner = self._resolve(abs_dir)
|
|
189
|
+
try:
|
|
190
|
+
fs.rmdir(inner)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
raise
|
|
194
|
+
|
|
131
195
|
def _is_mount_point(self, abs_path: str) -> bool:
|
|
132
196
|
"""Check if abs_path is a mount prefix or an implicit parent of one."""
|
|
133
197
|
norm = abs_path.rstrip("/") if abs_path != "/" else ""
|
|
@@ -190,11 +254,13 @@ class MountFS:
|
|
|
190
254
|
synthesized over its ``read()``/``write()``, so mounting a
|
|
191
255
|
bytes-level filesystem does not make it unopenable.
|
|
192
256
|
"""
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
257
|
+
creates = any(c in mode for c in "wax")
|
|
258
|
+
with self._implied_parents(*([path] if creates else [])):
|
|
259
|
+
fs, inner = self._resolve(path)
|
|
260
|
+
backend_open = getattr(fs, "open", None)
|
|
261
|
+
if backend_open is not None:
|
|
262
|
+
return backend_open(inner, mode, **kwargs)
|
|
263
|
+
return open_file(fs, inner, mode, **kwargs)
|
|
198
264
|
|
|
199
265
|
def read(self, path: str, offset: int = 0, size: int = -1) -> bytes:
|
|
200
266
|
"""Read bytes from the filesystem that owns ``path``, range and all.
|
|
@@ -317,7 +383,22 @@ class MountFS:
|
|
|
317
383
|
return result
|
|
318
384
|
|
|
319
385
|
def access(self, path: str, mode: int) -> bool:
|
|
320
|
-
|
|
386
|
+
"""Answer ``os.access()`` for where a create at ``path`` would land.
|
|
387
|
+
|
|
388
|
+
An implied directory exists here whether or not the filesystem
|
|
389
|
+
owning its path holds it, and what is created in it is made
|
|
390
|
+
there (see ``_implied_parents``). Where that filesystem lacks
|
|
391
|
+
it, the nearest directory up the path that is real answers:
|
|
392
|
+
that is what a caller asking "may I write here" before creating
|
|
393
|
+
a file has to be told.
|
|
394
|
+
"""
|
|
395
|
+
probe = self._to_absolute(path)
|
|
396
|
+
while self._is_implied_dir(probe):
|
|
397
|
+
fs, inner = self._resolve(probe)
|
|
398
|
+
if fs.isdir(inner):
|
|
399
|
+
break
|
|
400
|
+
probe = posixpath.dirname(probe)
|
|
401
|
+
fs, inner = self._resolve(probe)
|
|
321
402
|
return fs.access(inner, mode)
|
|
322
403
|
|
|
323
404
|
def getsize(self, path: str) -> int:
|
|
@@ -430,8 +511,9 @@ class MountFS:
|
|
|
430
511
|
# -- Write operations --
|
|
431
512
|
|
|
432
513
|
def write(self, path: str, content: bytes, mode: str = "w") -> None:
|
|
433
|
-
|
|
434
|
-
|
|
514
|
+
with self._implied_parents(path):
|
|
515
|
+
fs, inner = self._resolve(path)
|
|
516
|
+
fs.write(inner, content, mode=mode)
|
|
435
517
|
|
|
436
518
|
def write_many(self, files: dict[str, bytes]) -> None:
|
|
437
519
|
# Group by filesystem
|
|
@@ -442,8 +524,9 @@ class MountFS:
|
|
|
442
524
|
if key not in groups:
|
|
443
525
|
groups[key] = (fs, {})
|
|
444
526
|
groups[key][1][inner] = content
|
|
445
|
-
|
|
446
|
-
fs.
|
|
527
|
+
with self._implied_parents(*files):
|
|
528
|
+
for fs, inner_files in groups.values():
|
|
529
|
+
fs.write_many(inner_files)
|
|
447
530
|
|
|
448
531
|
def remove(self, path: str) -> None:
|
|
449
532
|
fs, inner = self._resolve(path)
|
|
@@ -462,8 +545,9 @@ class MountFS:
|
|
|
462
545
|
fs.remove_many(inner_paths)
|
|
463
546
|
|
|
464
547
|
def mkdir(self, path: str, **kwargs: Any) -> None:
|
|
465
|
-
|
|
466
|
-
|
|
548
|
+
with self._implied_parents(path):
|
|
549
|
+
fs, inner = self._resolve(path)
|
|
550
|
+
fs.mkdir(inner, **kwargs)
|
|
467
551
|
|
|
468
552
|
def makedirs(self, path: str, exist_ok: bool = True) -> None:
|
|
469
553
|
fs, inner = self._resolve(path)
|
|
@@ -476,35 +560,41 @@ class MountFS:
|
|
|
476
560
|
def rename(self, src: str, dst: str) -> None:
|
|
477
561
|
src_fs, src_inner = self._resolve(src)
|
|
478
562
|
dst_fs, dst_inner = self._resolve(dst)
|
|
479
|
-
|
|
480
|
-
src_fs
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
563
|
+
with self._implied_parents(dst):
|
|
564
|
+
if src_fs is dst_fs:
|
|
565
|
+
src_fs.rename(src_inner, dst_inner)
|
|
566
|
+
else:
|
|
567
|
+
if src_fs.isdir(src_inner):
|
|
568
|
+
raise OSError(
|
|
569
|
+
errno.EXDEV, "Cross-mount directory rename not supported"
|
|
570
|
+
)
|
|
571
|
+
content = src_fs.read(src_inner)
|
|
572
|
+
dst_fs.write(dst_inner, content)
|
|
573
|
+
src_fs.remove(src_inner)
|
|
487
574
|
|
|
488
575
|
def replace(self, src: str, dst: str) -> None:
|
|
489
576
|
src_fs, src_inner = self._resolve(src)
|
|
490
577
|
dst_fs, dst_inner = self._resolve(dst)
|
|
491
|
-
|
|
492
|
-
src_fs
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
578
|
+
with self._implied_parents(dst):
|
|
579
|
+
if src_fs is dst_fs:
|
|
580
|
+
src_fs.replace(src_inner, dst_inner)
|
|
581
|
+
else:
|
|
582
|
+
content = src_fs.read(src_inner)
|
|
583
|
+
dst_fs.write(dst_inner, content)
|
|
584
|
+
src_fs.remove(src_inner)
|
|
497
585
|
|
|
498
586
|
def symlink(self, src: str, dst: str) -> None:
|
|
499
|
-
|
|
500
|
-
|
|
587
|
+
with self._implied_parents(dst):
|
|
588
|
+
fs, inner = self._resolve(dst)
|
|
589
|
+
fs.symlink(src, inner)
|
|
501
590
|
|
|
502
591
|
def link(self, src: str, dst: str) -> None:
|
|
503
592
|
src_fs, src_inner = self._resolve(src)
|
|
504
593
|
dst_fs, dst_inner = self._resolve(dst)
|
|
505
594
|
if src_fs is not dst_fs:
|
|
506
595
|
raise OSError(errno.EXDEV, "Cross-mount link not supported")
|
|
507
|
-
|
|
596
|
+
with self._implied_parents(dst):
|
|
597
|
+
src_fs.link(src_inner, dst_inner)
|
|
508
598
|
|
|
509
599
|
def chmod(self, path: str, mode: int) -> None:
|
|
510
600
|
fs, inner = self._resolve(path)
|
|
@@ -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
|
|
|
@@ -1094,11 +1106,12 @@ class VirtualFS:
|
|
|
1094
1106
|
if parent and not self.isdir("/" + parent):
|
|
1095
1107
|
raise FileNotFoundError(f"No such file or directory: '{path}'")
|
|
1096
1108
|
|
|
1097
|
-
# Check if already exists
|
|
1098
|
-
|
|
1099
|
-
|
|
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):
|
|
1100
1113
|
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1101
|
-
if self.isdir(
|
|
1114
|
+
if self.isdir(absolute):
|
|
1102
1115
|
if exist_ok:
|
|
1103
1116
|
return
|
|
1104
1117
|
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
@@ -1130,8 +1143,8 @@ class VirtualFS:
|
|
|
1130
1143
|
"""
|
|
1131
1144
|
path = self.resolve_path(path)
|
|
1132
1145
|
|
|
1133
|
-
if not exist_ok and self.isdir(path):
|
|
1134
|
-
absolute =
|
|
1146
|
+
if not exist_ok and self.isdir(self._absolute(path)):
|
|
1147
|
+
absolute = self._absolute(path)
|
|
1135
1148
|
raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), absolute)
|
|
1136
1149
|
|
|
1137
1150
|
parts = path.strip("/").split("/")
|
|
@@ -1157,16 +1170,19 @@ class VirtualFS:
|
|
|
1157
1170
|
"""
|
|
1158
1171
|
path = self.resolve_path(path)
|
|
1159
1172
|
normalized = self._normalize_path(path)
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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}")
|
|
1167
1183
|
|
|
1168
1184
|
# Check if directory is empty
|
|
1169
|
-
children = self.list(
|
|
1185
|
+
children = self.list(absolute)
|
|
1170
1186
|
if children:
|
|
1171
1187
|
raise OSError(f"Directory not empty: {path}")
|
|
1172
1188
|
|
|
@@ -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")
|
|
@@ -600,3 +600,149 @@ class TestNestedMountListing:
|
|
|
600
600
|
"missing/deep",
|
|
601
601
|
"missing/deep/leaf.txt",
|
|
602
602
|
]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class TestImpliedDirectories:
|
|
606
|
+
"""A directory the composition shows on the way to a mount takes
|
|
607
|
+
creates, whether or not the filesystem owning its path holds it."""
|
|
608
|
+
|
|
609
|
+
@staticmethod
|
|
610
|
+
def _composed(mounts=("/workspace/docs",), base=None):
|
|
611
|
+
base = VirtualFS() if base is None else base
|
|
612
|
+
docs = VirtualFS()
|
|
613
|
+
docs.write("README.md", b"reference")
|
|
614
|
+
return base, MountFS(base, {m: ReadOnlyFS(docs) for m in mounts})
|
|
615
|
+
|
|
616
|
+
def test_open_for_write_in_an_implied_directory(self):
|
|
617
|
+
base, fs = self._composed()
|
|
618
|
+
assert fs.isdir("/workspace") and not base.isdir("workspace")
|
|
619
|
+
with fs.open("/workspace/a.txt", "w") as f:
|
|
620
|
+
f.write("x")
|
|
621
|
+
assert base.isdir("workspace")
|
|
622
|
+
assert base.read("workspace/a.txt") == b"x"
|
|
623
|
+
|
|
624
|
+
def test_patched_open_in_an_implied_directory(self):
|
|
625
|
+
base, fs = self._composed()
|
|
626
|
+
with patch(fs):
|
|
627
|
+
with open("/workspace/a.txt", "w") as f:
|
|
628
|
+
f.write("x")
|
|
629
|
+
with open("/workspace/b.bin", "xb") as f:
|
|
630
|
+
f.write(b"y")
|
|
631
|
+
with open("/workspace/c.txt", "a") as f:
|
|
632
|
+
f.write("z")
|
|
633
|
+
assert base.read("workspace/a.txt") == b"x"
|
|
634
|
+
assert base.read("workspace/b.bin") == b"y"
|
|
635
|
+
assert base.read("workspace/c.txt") == b"z"
|
|
636
|
+
|
|
637
|
+
def test_write_mkdir_and_rename_into_an_implied_directory(self):
|
|
638
|
+
base, fs = self._composed()
|
|
639
|
+
fs.mkdir("/workspace/sub")
|
|
640
|
+
assert base.isdir("workspace/sub")
|
|
641
|
+
|
|
642
|
+
base2, fs2 = self._composed()
|
|
643
|
+
fs2.write("/workspace/w.txt", b"w")
|
|
644
|
+
assert base2.read("workspace/w.txt") == b"w"
|
|
645
|
+
|
|
646
|
+
base3, fs3 = self._composed()
|
|
647
|
+
fs3.write("/top.txt", b"t")
|
|
648
|
+
fs3.rename("/top.txt", "/workspace/moved.txt")
|
|
649
|
+
assert base3.read("workspace/moved.txt") == b"t"
|
|
650
|
+
|
|
651
|
+
def test_every_implied_level_takes_a_create(self):
|
|
652
|
+
base, fs = self._composed(mounts=("/workspace/inputs/docs",))
|
|
653
|
+
fs.write("/workspace/a.txt", b"a")
|
|
654
|
+
with fs.open("/workspace/inputs/b.txt", "w") as f:
|
|
655
|
+
f.write("b")
|
|
656
|
+
assert base.read("workspace/a.txt") == b"a"
|
|
657
|
+
assert base.read("workspace/inputs/b.txt") == b"b"
|
|
658
|
+
|
|
659
|
+
def test_an_implied_directory_inside_a_mount(self):
|
|
660
|
+
outer = VirtualFS()
|
|
661
|
+
deep = VirtualFS()
|
|
662
|
+
deep.write("leaf.txt", b"x")
|
|
663
|
+
fs = MountFS(VirtualFS(), {"/a": outer, "/a/missing/deep": deep})
|
|
664
|
+
with fs.open("/a/missing/new.txt", "w") as f:
|
|
665
|
+
f.write("n")
|
|
666
|
+
assert outer.read("missing/new.txt") == b"n"
|
|
667
|
+
|
|
668
|
+
def test_a_missing_directory_leading_to_no_mount_stays_missing(self):
|
|
669
|
+
base, fs = self._composed()
|
|
670
|
+
with pytest.raises(FileNotFoundError):
|
|
671
|
+
fs.open("/workspace/new/b.txt", "w")
|
|
672
|
+
assert not base.exists("workspace/new")
|
|
673
|
+
|
|
674
|
+
def test_a_read_leaves_the_base_alone(self):
|
|
675
|
+
base, fs = self._composed()
|
|
676
|
+
with pytest.raises(FileNotFoundError):
|
|
677
|
+
fs.open("/workspace/a.txt", "r")
|
|
678
|
+
assert fs.access("/workspace", os.W_OK)
|
|
679
|
+
assert not base.exists("workspace")
|
|
680
|
+
|
|
681
|
+
def test_access_answers_for_where_a_create_lands(self):
|
|
682
|
+
_, fs = self._composed(mounts=("/workspace/inputs/docs",))
|
|
683
|
+
for path in ("/workspace", "/workspace/inputs"):
|
|
684
|
+
assert fs.access(path, os.F_OK)
|
|
685
|
+
assert fs.access(path, os.W_OK)
|
|
686
|
+
assert not fs.access("/workspace/inputs/docs", os.W_OK)
|
|
687
|
+
|
|
688
|
+
def test_a_read_only_base_refuses_the_create(self):
|
|
689
|
+
_, fs = self._composed(base=ReadOnlyFS(VirtualFS()))
|
|
690
|
+
assert not fs.access("/workspace", os.W_OK)
|
|
691
|
+
with pytest.raises(PermissionError):
|
|
692
|
+
fs.open("/workspace/a.txt", "w")
|
|
693
|
+
|
|
694
|
+
def test_an_isolated_base_gets_a_real_directory(self, tmp_path):
|
|
695
|
+
base, fs = self._composed(base=IsolatedFS(str(tmp_path)))
|
|
696
|
+
with fs.open("/workspace/a.txt", "w") as f:
|
|
697
|
+
f.write("x")
|
|
698
|
+
assert (tmp_path / "workspace" / "a.txt").read_text() == "x"
|
|
699
|
+
|
|
700
|
+
def test_a_failed_rename_leaves_the_base_as_it_was(self):
|
|
701
|
+
base, fs = self._composed()
|
|
702
|
+
with pytest.raises(FileNotFoundError):
|
|
703
|
+
fs.rename("/missing", "/workspace/new")
|
|
704
|
+
assert not base.exists("workspace")
|
|
705
|
+
|
|
706
|
+
def test_a_refused_cross_mount_rename_leaves_the_base_as_it_was(self):
|
|
707
|
+
base = VirtualFS()
|
|
708
|
+
outer = VirtualFS()
|
|
709
|
+
outer.makedirs("tree")
|
|
710
|
+
fs = MountFS(
|
|
711
|
+
base, {"/outer": outer, "/workspace/docs": ReadOnlyFS(VirtualFS())}
|
|
712
|
+
)
|
|
713
|
+
with pytest.raises(OSError) as raised:
|
|
714
|
+
fs.rename("/outer/tree", "/workspace/tree")
|
|
715
|
+
assert raised.value.errno == errno.EXDEV
|
|
716
|
+
assert not base.exists("workspace")
|
|
717
|
+
|
|
718
|
+
def test_an_invalid_open_mode_leaves_the_base_as_it_was(self, tmp_path):
|
|
719
|
+
_, fs = self._composed(base=IsolatedFS(str(tmp_path)))
|
|
720
|
+
with pytest.raises(ValueError):
|
|
721
|
+
fs.open("/workspace/a.txt", "wq")
|
|
722
|
+
assert not (tmp_path / "workspace").exists()
|
|
723
|
+
|
|
724
|
+
def test_every_level_made_for_a_failed_create_is_removed(self):
|
|
725
|
+
base, fs = self._composed(mounts=("/workspace/inputs/docs",))
|
|
726
|
+
with pytest.raises(FileNotFoundError):
|
|
727
|
+
fs.rename("/missing", "/workspace/inputs/new")
|
|
728
|
+
assert not base.exists("workspace")
|
|
729
|
+
|
|
730
|
+
def test_a_directory_something_landed_in_stays(self):
|
|
731
|
+
class Partial(VirtualFS):
|
|
732
|
+
def write_many(self, files):
|
|
733
|
+
first, *_ = files
|
|
734
|
+
self.write(first, files[first])
|
|
735
|
+
raise OSError(errno.ENOSPC, "full")
|
|
736
|
+
|
|
737
|
+
base = Partial()
|
|
738
|
+
fs = MountFS(base, {"/workspace/docs": ReadOnlyFS(VirtualFS())})
|
|
739
|
+
with pytest.raises(OSError):
|
|
740
|
+
fs.write_many({"/workspace/a.txt": b"a", "/workspace/b.txt": b"b"})
|
|
741
|
+
assert base.read("workspace/a.txt") == b"a"
|
|
742
|
+
|
|
743
|
+
def test_a_directory_the_base_already_held_is_never_removed(self):
|
|
744
|
+
base, fs = self._composed()
|
|
745
|
+
base.makedirs("workspace")
|
|
746
|
+
with pytest.raises(FileNotFoundError):
|
|
747
|
+
fs.rename("/missing", "/workspace/new")
|
|
748
|
+
assert base.isdir("workspace")
|
|
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
|
|
File without changes
|
|
File without changes
|