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.
Files changed (46) hide show
  1. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/PKG-INFO +1 -1
  2. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/mount.py +120 -30
  3. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/virtual.py +30 -14
  4. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/PKG-INFO +1 -1
  5. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/pyproject.toml +1 -1
  6. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_directory_ops.py +60 -0
  7. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_mount.py +146 -0
  8. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/LICENSE +0 -0
  9. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/README.md +0 -0
  10. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/__init__.py +0 -0
  11. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/base.py +0 -0
  12. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/conformance.py +0 -0
  13. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/context.py +0 -0
  14. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/isolated.py +0 -0
  15. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/__init__.py +0 -0
  16. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/core.py +0 -0
  17. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/fdtable.py +0 -0
  18. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/install.py +0 -0
  19. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/patching/patches.py +0 -0
  20. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/py.typed +0 -0
  21. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/readonly.py +0 -0
  22. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs/virtualfile.py +0 -0
  23. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/SOURCES.txt +0 -0
  24. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/dependency_links.txt +0 -0
  25. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/requires.txt +0 -0
  26. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/monkeyfs.egg-info/top_level.txt +0 -0
  27. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/setup.cfg +0 -0
  28. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_bulk_operations.py +0 -0
  29. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_conformance.py +0 -0
  30. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_expanduser.py +0 -0
  31. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_fd_emulation.py +0 -0
  32. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_finalization_and_invalidate.py +0 -0
  33. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_isolated.py +0 -0
  34. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_lazy_binary_read.py +0 -0
  35. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_metadata.py +0 -0
  36. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_open_optional.py +0 -0
  37. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_patching.py +0 -0
  38. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_patching_pathlib.py +0 -0
  39. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_patching_safe_paths.py +0 -0
  40. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_protocol.py +0 -0
  41. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_ranged_read.py +0 -0
  42. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_readonly.py +0 -0
  43. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_symlink_entries.py +0 -0
  44. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_vfs_optional.py +0 -0
  45. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_vfs_size_limit.py +0 -0
  46. {monkeyfs-0.2.4 → monkeyfs-0.2.6}/tests/test_virtual.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monkeyfs
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Transparent filesystem interception via monkey-patching.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -9,7 +9,9 @@ from __future__ import annotations
9
9
 
10
10
  import errno
11
11
  import os
12
- from collections.abc import Iterable
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
- fs, inner = self._resolve(path)
194
- backend_open = getattr(fs, "open", None)
195
- if backend_open is not None:
196
- return backend_open(inner, mode, **kwargs)
197
- return open_file(fs, inner, mode, **kwargs)
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
- fs, inner = self._resolve(path)
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
- fs, inner = self._resolve(path)
434
- fs.write(inner, content, mode=mode)
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
- for fs, inner_files in groups.values():
446
- fs.write_many(inner_files)
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
- fs, inner = self._resolve(path)
466
- fs.mkdir(inner, **kwargs)
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
- if src_fs is dst_fs:
480
- src_fs.rename(src_inner, dst_inner)
481
- else:
482
- if src_fs.isdir(src_inner):
483
- raise OSError(errno.EXDEV, "Cross-mount directory rename not supported")
484
- content = src_fs.read(src_inner)
485
- dst_fs.write(dst_inner, content)
486
- src_fs.remove(src_inner)
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
- if src_fs is dst_fs:
492
- src_fs.replace(src_inner, dst_inner)
493
- else:
494
- content = src_fs.read(src_inner)
495
- dst_fs.write(dst_inner, content)
496
- src_fs.remove(src_inner)
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
- fs, inner = self._resolve(dst)
500
- fs.symlink(src, inner)
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
- src_fs.link(src_inner, dst_inner)
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
- absolute = "/" + normalized
1099
- if self.isfile(path):
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(path):
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 = "/" + path.strip("/")
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
- if not self.exists(path):
1162
- raise FileNotFoundError(f"No such directory: {path}")
1163
- if self.isfile(path):
1164
- raise NotADirectoryError(f"Not a directory: {path}")
1165
- if not self.isdir(path):
1166
- raise FileNotFoundError(f"No such directory: {path}")
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(path)
1185
+ children = self.list(absolute)
1170
1186
  if children:
1171
1187
  raise OSError(f"Directory not empty: {path}")
1172
1188
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monkeyfs
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Transparent filesystem interception via monkey-patching.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "monkeyfs"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Transparent filesystem interception via monkey-patching."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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