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.
Files changed (46) hide show
  1. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/PKG-INFO +1 -1
  2. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/base.py +7 -3
  3. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/conformance.py +117 -7
  4. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/isolated.py +12 -2
  5. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/mount.py +2 -1
  6. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/virtual.py +30 -11
  7. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/PKG-INFO +1 -1
  8. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/pyproject.toml +1 -1
  9. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_conformance.py +47 -0
  10. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_directory_ops.py +119 -0
  11. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_mount.py +23 -0
  12. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_open_optional.py +7 -2
  13. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_protocol.py +70 -0
  14. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/LICENSE +0 -0
  15. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/README.md +0 -0
  16. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/__init__.py +0 -0
  17. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/context.py +0 -0
  18. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/__init__.py +0 -0
  19. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/core.py +0 -0
  20. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/fdtable.py +0 -0
  21. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/install.py +0 -0
  22. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/patching/patches.py +0 -0
  23. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/py.typed +0 -0
  24. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/readonly.py +0 -0
  25. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs/virtualfile.py +0 -0
  26. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/SOURCES.txt +0 -0
  27. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/dependency_links.txt +0 -0
  28. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/requires.txt +0 -0
  29. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/monkeyfs.egg-info/top_level.txt +0 -0
  30. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/setup.cfg +0 -0
  31. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_bulk_operations.py +0 -0
  32. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_expanduser.py +0 -0
  33. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_fd_emulation.py +0 -0
  34. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_finalization_and_invalidate.py +0 -0
  35. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_isolated.py +0 -0
  36. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_lazy_binary_read.py +0 -0
  37. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_metadata.py +0 -0
  38. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_patching.py +0 -0
  39. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_patching_pathlib.py +0 -0
  40. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_patching_safe_paths.py +0 -0
  41. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_ranged_read.py +0 -0
  42. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_readonly.py +0 -0
  43. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_symlink_entries.py +0 -0
  44. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_vfs_optional.py +0 -0
  45. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_vfs_size_limit.py +0 -0
  46. {monkeyfs-0.2.0 → monkeyfs-0.2.2}/tests/test_virtual.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monkeyfs
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Transparent filesystem interception via monkey-patching.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -83,7 +83,11 @@ class FileInfo:
83
83
 
84
84
  Attributes:
85
85
  name: File or directory name (basename).
86
- path: Full path to file or directory.
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; a file written comes
12
- back through ``stat()``, ``list()`` and ``read()``; the ranged read means
13
- what it says, including the cases a backend that quietly ignores ``offset``
14
- and ``size`` would fail; ``open()`` round-trips bytes and text, through the
15
- backend's own if it has one and through the synthesized one under ``patch()``
16
- if it does not; and ``rename``/``remove`` leave the filesystem as they found
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
- rel_path = str(item.relative_to(self.root))
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=rel_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=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 (as file or dir when exist_ok=False).
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(f"File exists: {path}")
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(f"Directory exists: {path}")
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(f"File exists: {dir_path}")
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 name.
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=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=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=name,
1393
+ name=basename,
1375
1394
  path=display,
1376
1395
  size=len(content),
1377
1396
  created_at=now,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monkeyfs
3
- Version: 0.2.0
3
+ Version: 0.2.2
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.0"
3
+ version = "0.2.2"
4
4
  description = "Transparent filesystem interception via monkey-patching."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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=full,
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