just-bash 0.1.5__py3-none-any.whl

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 (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,504 @@
1
+ """
2
+ MountableFs Implementation
3
+
4
+ A filesystem that supports mounting multiple child filesystems at different paths.
5
+ Operations are routed to the appropriate filesystem based on the path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Optional
13
+
14
+ from ..types import FsStat, IFileSystem
15
+ from .in_memory_fs import InMemoryFs, DirentEntry
16
+
17
+
18
+ @dataclass
19
+ class MountConfig:
20
+ """Configuration for a mount point."""
21
+
22
+ mount_point: str
23
+ """Virtual path where the filesystem is mounted."""
24
+
25
+ filesystem: IFileSystem
26
+ """The filesystem to mount."""
27
+
28
+
29
+ @dataclass
30
+ class MountableFsOptions:
31
+ """Options for MountableFs."""
32
+
33
+ base: Optional[IFileSystem] = None
34
+ """Base filesystem for operations outside any mount. Defaults to InMemoryFs."""
35
+
36
+ mounts: list[MountConfig] = field(default_factory=list)
37
+ """Initial mounts to configure."""
38
+
39
+
40
+ class MountableFs:
41
+ """
42
+ Filesystem that supports mounting multiple child filesystems.
43
+
44
+ Operations are routed to the appropriate filesystem based on path.
45
+ The base filesystem handles paths outside any mount point.
46
+ """
47
+
48
+ def __init__(self, options: MountableFsOptions | None = None) -> None:
49
+ """
50
+ Initialize the mountable filesystem.
51
+
52
+ Args:
53
+ options: Configuration options
54
+ """
55
+ if options is None:
56
+ options = MountableFsOptions()
57
+
58
+ self._base = options.base if options.base is not None else InMemoryFs()
59
+ self._mounts: dict[str, IFileSystem] = {}
60
+
61
+ # Apply initial mounts
62
+ for mount_config in options.mounts:
63
+ self.mount(mount_config.mount_point, mount_config.filesystem)
64
+
65
+ def _normalize_path(self, path: str) -> str:
66
+ """Normalize a path (resolve ., .., trailing slashes)."""
67
+ if not path or path == "/":
68
+ return "/"
69
+
70
+ # Remove trailing slash
71
+ if path.endswith("/") and path != "/":
72
+ path = path[:-1]
73
+
74
+ # Ensure starts with /
75
+ if not path.startswith("/"):
76
+ path = "/" + path
77
+
78
+ # Resolve . and ..
79
+ parts = path.split("/")
80
+ resolved: list[str] = []
81
+
82
+ for part in parts:
83
+ if part == "" or part == ".":
84
+ continue
85
+ elif part == "..":
86
+ if resolved:
87
+ resolved.pop()
88
+ else:
89
+ resolved.append(part)
90
+
91
+ return "/" + "/".join(resolved) if resolved else "/"
92
+
93
+ def mount(self, path: str, filesystem: IFileSystem) -> None:
94
+ """
95
+ Mount a filesystem at the given path.
96
+
97
+ Args:
98
+ path: Virtual path where the filesystem will be mounted
99
+ filesystem: The filesystem to mount
100
+
101
+ Raises:
102
+ ValueError: If path is root, already mounted, or would create nested mounts
103
+ """
104
+ normalized = self._normalize_path(path)
105
+
106
+ if normalized == "/":
107
+ raise ValueError("Cannot mount at root '/'")
108
+
109
+ if normalized in self._mounts:
110
+ raise ValueError(f"Path '{path}' is already mounted")
111
+
112
+ # Note: Nested mounts are allowed. The longest matching prefix is used
113
+ # for path routing, so mounting /a/b/c under /a is valid and works as expected.
114
+
115
+ self._mounts[normalized] = filesystem
116
+
117
+ def unmount(self, path: str) -> None:
118
+ """
119
+ Unmount the filesystem at the given path.
120
+
121
+ Args:
122
+ path: Virtual path to unmount
123
+
124
+ Raises:
125
+ KeyError: If path is not mounted
126
+ """
127
+ normalized = self._normalize_path(path)
128
+ if normalized not in self._mounts:
129
+ raise KeyError(f"Path '{path}' is not mounted")
130
+ del self._mounts[normalized]
131
+
132
+ def get_mounts(self) -> list[str]:
133
+ """Get list of all mount points."""
134
+ return list(self._mounts.keys())
135
+
136
+ def is_mount_point(self, path: str) -> bool:
137
+ """Check if a path is a mount point."""
138
+ normalized = self._normalize_path(path)
139
+ return normalized in self._mounts
140
+
141
+ def _route_path(self, path: str) -> tuple[IFileSystem, str, str | None]:
142
+ """
143
+ Route a path to the appropriate filesystem.
144
+
145
+ Returns:
146
+ Tuple of (filesystem, relative_path, mount_point or None for base)
147
+ """
148
+ normalized = self._normalize_path(path)
149
+
150
+ # Find longest matching mount point
151
+ best_mount: str | None = None
152
+ best_length = 0
153
+
154
+ for mount_point in self._mounts:
155
+ if normalized == mount_point or normalized.startswith(mount_point + "/"):
156
+ if len(mount_point) > best_length:
157
+ best_mount = mount_point
158
+ best_length = len(mount_point)
159
+
160
+ if best_mount is not None:
161
+ # Strip mount point to get relative path
162
+ if normalized == best_mount:
163
+ relative = "/"
164
+ else:
165
+ relative = normalized[len(best_mount):]
166
+ return self._mounts[best_mount], relative, best_mount
167
+
168
+ # Use base filesystem
169
+ return self._base, normalized, None
170
+
171
+ def _is_virtual_directory(self, path: str) -> bool:
172
+ """Check if path is a virtual directory (parent of a mount point)."""
173
+ normalized = self._normalize_path(path)
174
+
175
+ for mount_point in self._mounts:
176
+ if mount_point.startswith(normalized + "/"):
177
+ return True
178
+
179
+ return False
180
+
181
+ def _get_virtual_children(self, path: str) -> list[str]:
182
+ """Get virtual child directories from mount points."""
183
+ normalized = self._normalize_path(path)
184
+ prefix = normalized + "/" if normalized != "/" else "/"
185
+ children: set[str] = set()
186
+
187
+ for mount_point in self._mounts:
188
+ if mount_point.startswith(prefix):
189
+ # Extract the immediate child
190
+ rest = mount_point[len(prefix):]
191
+ child = rest.split("/")[0]
192
+ if child:
193
+ children.add(child)
194
+
195
+ return list(children)
196
+
197
+ async def read_file(self, path: str, encoding: str = "utf-8") -> str:
198
+ """Read file contents as string."""
199
+ fs, rel_path, _ = self._route_path(path)
200
+ return await fs.read_file(rel_path, encoding)
201
+
202
+ async def read_file_bytes(self, path: str) -> bytes:
203
+ """Read file contents as bytes."""
204
+ fs, rel_path, _ = self._route_path(path)
205
+ return await fs.read_file_bytes(rel_path)
206
+
207
+ async def write_file(
208
+ self,
209
+ path: str,
210
+ content: str | bytes,
211
+ encoding: str = "utf-8",
212
+ ) -> None:
213
+ """Write content to file."""
214
+ fs, rel_path, _ = self._route_path(path)
215
+ await fs.write_file(rel_path, content, encoding)
216
+
217
+ async def append_file(
218
+ self,
219
+ path: str,
220
+ content: str | bytes,
221
+ encoding: str = "utf-8",
222
+ ) -> None:
223
+ """Append content to file."""
224
+ fs, rel_path, _ = self._route_path(path)
225
+ # IFileSystem.append_file doesn't have encoding, so convert to bytes if needed
226
+ if isinstance(content, str):
227
+ content = content.encode(encoding)
228
+ await fs.append_file(rel_path, content)
229
+
230
+ async def exists(self, path: str) -> bool:
231
+ """Check if path exists."""
232
+ normalized = self._normalize_path(path)
233
+
234
+ # Check if it's a mount point
235
+ if normalized in self._mounts:
236
+ return True
237
+
238
+ # Check if it's a virtual parent of a mount
239
+ if self._is_virtual_directory(normalized):
240
+ return True
241
+
242
+ fs, rel_path, _ = self._route_path(path)
243
+ return await fs.exists(rel_path)
244
+
245
+ async def is_file(self, path: str) -> bool:
246
+ """Check if path is a file."""
247
+ normalized = self._normalize_path(path)
248
+
249
+ # Mount points and virtual parents are directories
250
+ if normalized in self._mounts or self._is_virtual_directory(normalized):
251
+ return False
252
+
253
+ fs, rel_path, _ = self._route_path(path)
254
+ return await fs.is_file(rel_path)
255
+
256
+ async def is_directory(self, path: str) -> bool:
257
+ """Check if path is a directory."""
258
+ normalized = self._normalize_path(path)
259
+
260
+ # Mount points are directories
261
+ if normalized in self._mounts:
262
+ return True
263
+
264
+ # Virtual parents of mounts are directories
265
+ if self._is_virtual_directory(normalized):
266
+ return True
267
+
268
+ fs, rel_path, _ = self._route_path(path)
269
+ return await fs.is_directory(rel_path)
270
+
271
+ async def stat(self, path: str) -> FsStat:
272
+ """Get file/directory stats."""
273
+ normalized = self._normalize_path(path)
274
+
275
+ # Mount points are directories
276
+ if normalized in self._mounts:
277
+ return FsStat(
278
+ is_file=False,
279
+ is_directory=True,
280
+ is_symbolic_link=False,
281
+ mode=0o755,
282
+ size=0,
283
+ mtime=time.time(),
284
+ )
285
+
286
+ # Virtual parents of mounts are directories
287
+ if self._is_virtual_directory(normalized):
288
+ return FsStat(
289
+ is_file=False,
290
+ is_directory=True,
291
+ is_symbolic_link=False,
292
+ mode=0o755,
293
+ size=0,
294
+ mtime=time.time(),
295
+ )
296
+
297
+ fs, rel_path, _ = self._route_path(path)
298
+ return await fs.stat(rel_path)
299
+
300
+ async def lstat(self, path: str) -> FsStat:
301
+ """Get file/directory stats (does not follow final symlink)."""
302
+ normalized = self._normalize_path(path)
303
+
304
+ if normalized in self._mounts or self._is_virtual_directory(normalized):
305
+ return FsStat(
306
+ is_file=False,
307
+ is_directory=True,
308
+ is_symbolic_link=False,
309
+ mode=0o755,
310
+ size=0,
311
+ mtime=time.time(),
312
+ )
313
+
314
+ fs, rel_path, _ = self._route_path(path)
315
+ # Try lstat if available, fall back to stat
316
+ if hasattr(fs, "lstat"):
317
+ result: FsStat = await fs.lstat(rel_path)
318
+ return result
319
+ return await fs.stat(rel_path)
320
+
321
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
322
+ """Create a directory."""
323
+ normalized = self._normalize_path(path)
324
+
325
+ # Can't mkdir a mount point
326
+ if normalized in self._mounts:
327
+ if recursive:
328
+ return # Silently succeed
329
+ raise OSError(f"EEXIST: mount point already exists, mkdir '{path}'")
330
+
331
+ fs, rel_path, _ = self._route_path(path)
332
+ await fs.mkdir(rel_path, recursive)
333
+
334
+ async def readdir(self, path: str) -> list[str]:
335
+ """List directory contents."""
336
+ entries = await self.readdir_with_file_types(path)
337
+ return [e.name for e in entries]
338
+
339
+ async def readdir_with_file_types(self, path: str) -> list[DirentEntry]:
340
+ """List directory contents with type information."""
341
+ normalized = self._normalize_path(path)
342
+
343
+ # Collect entries from base/mount and virtual children
344
+ entries_map: dict[str, DirentEntry] = {}
345
+
346
+ # Get virtual children (mount point names or intermediate directories)
347
+ virtual_children = self._get_virtual_children(normalized)
348
+ for child in virtual_children:
349
+ child_path = f"{normalized}/{child}" if normalized != "/" else f"/{child}"
350
+ # Determine if it's a mount point or just a virtual directory
351
+ is_mount = child_path in self._mounts
352
+ entries_map[child] = DirentEntry(
353
+ name=child,
354
+ is_file=False,
355
+ is_directory=True,
356
+ is_symbolic_link=False,
357
+ )
358
+
359
+ # Get entries from the filesystem at this path
360
+ fs, rel_path, mount_point = self._route_path(normalized)
361
+
362
+ try:
363
+ if hasattr(fs, "readdir_with_file_types"):
364
+ fs_entries = await fs.readdir_with_file_types(rel_path)
365
+ for entry in fs_entries:
366
+ if entry.name not in entries_map:
367
+ entries_map[entry.name] = entry
368
+ else:
369
+ names = await fs.readdir(rel_path)
370
+ for name in names:
371
+ if name not in entries_map:
372
+ # Need to stat to get type info
373
+ full_path = f"{normalized}/{name}" if normalized != "/" else f"/{name}"
374
+ entries_map[name] = DirentEntry(
375
+ name=name,
376
+ is_file=await self.is_file(full_path),
377
+ is_directory=await self.is_directory(full_path),
378
+ is_symbolic_link=False,
379
+ )
380
+ except (FileNotFoundError, NotADirectoryError):
381
+ # Path doesn't exist in the filesystem but might have virtual children
382
+ if not entries_map:
383
+ raise
384
+
385
+ return sorted(entries_map.values(), key=lambda e: e.name)
386
+
387
+ async def rm(
388
+ self, path: str, recursive: bool = False, force: bool = False
389
+ ) -> None:
390
+ """Remove a file or directory."""
391
+ normalized = self._normalize_path(path)
392
+
393
+ # Can't remove mount points
394
+ if normalized in self._mounts:
395
+ raise OSError(f"EBUSY: cannot remove mount point, rm '{path}'")
396
+
397
+ # Can't remove virtual parents of mounts
398
+ if self._is_virtual_directory(normalized):
399
+ raise OSError(f"EBUSY: cannot remove virtual mount parent, rm '{path}'")
400
+
401
+ fs, rel_path, _ = self._route_path(path)
402
+ await fs.rm(rel_path, recursive, force)
403
+
404
+ async def cp(self, src: str, dest: str, recursive: bool = False) -> None:
405
+ """Copy a file or directory."""
406
+ src_fs, src_rel, src_mount = self._route_path(src)
407
+ dest_fs, dest_rel, dest_mount = self._route_path(dest)
408
+
409
+ # If same filesystem, delegate if cp method exists
410
+ if src_mount == dest_mount and hasattr(src_fs, "cp"):
411
+ await src_fs.cp(src_rel, dest_rel, recursive)
412
+ return
413
+
414
+ # Cross-filesystem copy: read from source, write to dest
415
+ if await self.is_directory(src):
416
+ if not recursive:
417
+ raise IsADirectoryError(f"EISDIR: is a directory, cp '{src}'")
418
+ await dest_fs.mkdir(dest_rel, recursive=True)
419
+ for child in await self.readdir(src):
420
+ src_child = f"{self._normalize_path(src)}/{child}"
421
+ dest_child = f"{self._normalize_path(dest)}/{child}"
422
+ await self.cp(src_child, dest_child, recursive=True)
423
+ else:
424
+ content = await src_fs.read_file_bytes(src_rel)
425
+ await dest_fs.write_file(dest_rel, content)
426
+
427
+ async def mv(self, src: str, dest: str) -> None:
428
+ """Move a file or directory."""
429
+ await self.cp(src, dest, recursive=True)
430
+ await self.rm(src, recursive=True)
431
+
432
+ async def chmod(self, path: str, mode: int) -> None:
433
+ """Change file/directory permissions."""
434
+ fs, rel_path, _ = self._route_path(path)
435
+ await fs.chmod(rel_path, mode)
436
+
437
+ async def symlink(self, target: str, link_path: str) -> None:
438
+ """Create a symbolic link."""
439
+ fs, rel_path, _ = self._route_path(link_path)
440
+ await fs.symlink(target, rel_path)
441
+
442
+ async def link(self, existing_path: str, new_path: str) -> None:
443
+ """Create a hard link (must be within same filesystem)."""
444
+ _, _, existing_mount = self._route_path(existing_path)
445
+ _, _, new_mount = self._route_path(new_path)
446
+
447
+ if existing_mount != new_mount:
448
+ raise OSError(
449
+ f"EXDEV: cross-device link not permitted, link '{existing_path}' -> '{new_path}'"
450
+ )
451
+
452
+ fs, existing_rel, _ = self._route_path(existing_path)
453
+ _, new_rel, _ = self._route_path(new_path)
454
+ if hasattr(fs, "link"):
455
+ await fs.link(existing_rel, new_rel)
456
+ else:
457
+ raise NotImplementedError(f"Filesystem does not support hard links")
458
+
459
+ async def readlink(self, path: str) -> str:
460
+ """Read the target of a symbolic link."""
461
+ fs, rel_path, _ = self._route_path(path)
462
+ return await fs.readlink(rel_path)
463
+
464
+ def resolve_path(self, base: str, path: str) -> str:
465
+ """Resolve a path relative to a base."""
466
+ if path.startswith("/"):
467
+ return self._normalize_path(path)
468
+ combined = f"/{path}" if base == "/" else f"{base}/{path}"
469
+ return self._normalize_path(combined)
470
+
471
+ def get_all_paths(self) -> list[str]:
472
+ """Get all paths in the filesystem."""
473
+ paths: set[str] = set()
474
+
475
+ # Get paths from base
476
+ if hasattr(self._base, "get_all_paths"):
477
+ for p in self._base.get_all_paths():
478
+ # Skip paths that would be under mounts
479
+ is_under_mount = False
480
+ for mount in self._mounts:
481
+ if p == mount or p.startswith(mount + "/"):
482
+ is_under_mount = True
483
+ break
484
+ if not is_under_mount:
485
+ paths.add(p)
486
+
487
+ # Get paths from each mount
488
+ for mount_point, fs in self._mounts.items():
489
+ paths.add(mount_point)
490
+ if hasattr(fs, "get_all_paths"):
491
+ for p in fs.get_all_paths():
492
+ if p == "/":
493
+ continue
494
+ paths.add(f"{mount_point}{p}")
495
+
496
+ # Add virtual parent directories
497
+ for mount_point in self._mounts:
498
+ parts = mount_point.split("/")
499
+ for i in range(1, len(parts)):
500
+ parent = "/".join(parts[:i]) or "/"
501
+ if parent != "/":
502
+ paths.add(parent)
503
+
504
+ return sorted(paths)