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.
- just_bash/__init__.py +55 -0
- just_bash/ast/__init__.py +213 -0
- just_bash/ast/factory.py +320 -0
- just_bash/ast/types.py +953 -0
- just_bash/bash.py +220 -0
- just_bash/commands/__init__.py +23 -0
- just_bash/commands/argv/__init__.py +5 -0
- just_bash/commands/argv/argv.py +21 -0
- just_bash/commands/awk/__init__.py +5 -0
- just_bash/commands/awk/awk.py +1168 -0
- just_bash/commands/base64/__init__.py +5 -0
- just_bash/commands/base64/base64.py +138 -0
- just_bash/commands/basename/__init__.py +5 -0
- just_bash/commands/basename/basename.py +72 -0
- just_bash/commands/bash/__init__.py +5 -0
- just_bash/commands/bash/bash.py +188 -0
- just_bash/commands/cat/__init__.py +5 -0
- just_bash/commands/cat/cat.py +173 -0
- just_bash/commands/checksum/__init__.py +5 -0
- just_bash/commands/checksum/checksum.py +179 -0
- just_bash/commands/chmod/__init__.py +5 -0
- just_bash/commands/chmod/chmod.py +216 -0
- just_bash/commands/column/__init__.py +5 -0
- just_bash/commands/column/column.py +180 -0
- just_bash/commands/comm/__init__.py +5 -0
- just_bash/commands/comm/comm.py +150 -0
- just_bash/commands/compression/__init__.py +5 -0
- just_bash/commands/compression/compression.py +298 -0
- just_bash/commands/cp/__init__.py +5 -0
- just_bash/commands/cp/cp.py +149 -0
- just_bash/commands/curl/__init__.py +5 -0
- just_bash/commands/curl/curl.py +801 -0
- just_bash/commands/cut/__init__.py +5 -0
- just_bash/commands/cut/cut.py +327 -0
- just_bash/commands/date/__init__.py +5 -0
- just_bash/commands/date/date.py +258 -0
- just_bash/commands/diff/__init__.py +5 -0
- just_bash/commands/diff/diff.py +118 -0
- just_bash/commands/dirname/__init__.py +5 -0
- just_bash/commands/dirname/dirname.py +56 -0
- just_bash/commands/du/__init__.py +5 -0
- just_bash/commands/du/du.py +150 -0
- just_bash/commands/echo/__init__.py +5 -0
- just_bash/commands/echo/echo.py +125 -0
- just_bash/commands/env/__init__.py +5 -0
- just_bash/commands/env/env.py +163 -0
- just_bash/commands/expand/__init__.py +5 -0
- just_bash/commands/expand/expand.py +299 -0
- just_bash/commands/expr/__init__.py +5 -0
- just_bash/commands/expr/expr.py +273 -0
- just_bash/commands/file/__init__.py +5 -0
- just_bash/commands/file/file.py +274 -0
- just_bash/commands/find/__init__.py +5 -0
- just_bash/commands/find/find.py +623 -0
- just_bash/commands/fold/__init__.py +5 -0
- just_bash/commands/fold/fold.py +160 -0
- just_bash/commands/grep/__init__.py +5 -0
- just_bash/commands/grep/grep.py +418 -0
- just_bash/commands/head/__init__.py +5 -0
- just_bash/commands/head/head.py +167 -0
- just_bash/commands/help/__init__.py +5 -0
- just_bash/commands/help/help.py +67 -0
- just_bash/commands/hostname/__init__.py +5 -0
- just_bash/commands/hostname/hostname.py +21 -0
- just_bash/commands/html_to_markdown/__init__.py +5 -0
- just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
- just_bash/commands/join/__init__.py +5 -0
- just_bash/commands/join/join.py +252 -0
- just_bash/commands/jq/__init__.py +5 -0
- just_bash/commands/jq/jq.py +280 -0
- just_bash/commands/ln/__init__.py +5 -0
- just_bash/commands/ln/ln.py +127 -0
- just_bash/commands/ls/__init__.py +5 -0
- just_bash/commands/ls/ls.py +280 -0
- just_bash/commands/mkdir/__init__.py +5 -0
- just_bash/commands/mkdir/mkdir.py +92 -0
- just_bash/commands/mv/__init__.py +5 -0
- just_bash/commands/mv/mv.py +142 -0
- just_bash/commands/nl/__init__.py +5 -0
- just_bash/commands/nl/nl.py +180 -0
- just_bash/commands/od/__init__.py +5 -0
- just_bash/commands/od/od.py +157 -0
- just_bash/commands/paste/__init__.py +5 -0
- just_bash/commands/paste/paste.py +100 -0
- just_bash/commands/printf/__init__.py +5 -0
- just_bash/commands/printf/printf.py +157 -0
- just_bash/commands/pwd/__init__.py +5 -0
- just_bash/commands/pwd/pwd.py +23 -0
- just_bash/commands/read/__init__.py +5 -0
- just_bash/commands/read/read.py +185 -0
- just_bash/commands/readlink/__init__.py +5 -0
- just_bash/commands/readlink/readlink.py +86 -0
- just_bash/commands/registry.py +844 -0
- just_bash/commands/rev/__init__.py +5 -0
- just_bash/commands/rev/rev.py +74 -0
- just_bash/commands/rg/__init__.py +5 -0
- just_bash/commands/rg/rg.py +1048 -0
- just_bash/commands/rm/__init__.py +5 -0
- just_bash/commands/rm/rm.py +106 -0
- just_bash/commands/search_engine/__init__.py +13 -0
- just_bash/commands/search_engine/matcher.py +170 -0
- just_bash/commands/search_engine/regex.py +159 -0
- just_bash/commands/sed/__init__.py +5 -0
- just_bash/commands/sed/sed.py +863 -0
- just_bash/commands/seq/__init__.py +5 -0
- just_bash/commands/seq/seq.py +190 -0
- just_bash/commands/shell/__init__.py +5 -0
- just_bash/commands/shell/shell.py +206 -0
- just_bash/commands/sleep/__init__.py +5 -0
- just_bash/commands/sleep/sleep.py +62 -0
- just_bash/commands/sort/__init__.py +5 -0
- just_bash/commands/sort/sort.py +411 -0
- just_bash/commands/split/__init__.py +5 -0
- just_bash/commands/split/split.py +237 -0
- just_bash/commands/sqlite3/__init__.py +5 -0
- just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
- just_bash/commands/stat/__init__.py +5 -0
- just_bash/commands/stat/stat.py +150 -0
- just_bash/commands/strings/__init__.py +5 -0
- just_bash/commands/strings/strings.py +150 -0
- just_bash/commands/tac/__init__.py +5 -0
- just_bash/commands/tac/tac.py +158 -0
- just_bash/commands/tail/__init__.py +5 -0
- just_bash/commands/tail/tail.py +180 -0
- just_bash/commands/tar/__init__.py +5 -0
- just_bash/commands/tar/tar.py +1067 -0
- just_bash/commands/tee/__init__.py +5 -0
- just_bash/commands/tee/tee.py +63 -0
- just_bash/commands/timeout/__init__.py +5 -0
- just_bash/commands/timeout/timeout.py +188 -0
- just_bash/commands/touch/__init__.py +5 -0
- just_bash/commands/touch/touch.py +91 -0
- just_bash/commands/tr/__init__.py +5 -0
- just_bash/commands/tr/tr.py +297 -0
- just_bash/commands/tree/__init__.py +5 -0
- just_bash/commands/tree/tree.py +139 -0
- just_bash/commands/true/__init__.py +5 -0
- just_bash/commands/true/true.py +32 -0
- just_bash/commands/uniq/__init__.py +5 -0
- just_bash/commands/uniq/uniq.py +323 -0
- just_bash/commands/wc/__init__.py +5 -0
- just_bash/commands/wc/wc.py +169 -0
- just_bash/commands/which/__init__.py +5 -0
- just_bash/commands/which/which.py +52 -0
- just_bash/commands/xan/__init__.py +5 -0
- just_bash/commands/xan/xan.py +1663 -0
- just_bash/commands/xargs/__init__.py +5 -0
- just_bash/commands/xargs/xargs.py +136 -0
- just_bash/commands/yq/__init__.py +5 -0
- just_bash/commands/yq/yq.py +848 -0
- just_bash/fs/__init__.py +29 -0
- just_bash/fs/in_memory_fs.py +621 -0
- just_bash/fs/mountable_fs.py +504 -0
- just_bash/fs/overlay_fs.py +894 -0
- just_bash/fs/read_write_fs.py +455 -0
- just_bash/interpreter/__init__.py +37 -0
- just_bash/interpreter/builtins/__init__.py +92 -0
- just_bash/interpreter/builtins/alias.py +154 -0
- just_bash/interpreter/builtins/cd.py +76 -0
- just_bash/interpreter/builtins/control.py +127 -0
- just_bash/interpreter/builtins/declare.py +336 -0
- just_bash/interpreter/builtins/export.py +56 -0
- just_bash/interpreter/builtins/let.py +44 -0
- just_bash/interpreter/builtins/local.py +57 -0
- just_bash/interpreter/builtins/mapfile.py +152 -0
- just_bash/interpreter/builtins/misc.py +378 -0
- just_bash/interpreter/builtins/readonly.py +80 -0
- just_bash/interpreter/builtins/set.py +234 -0
- just_bash/interpreter/builtins/shopt.py +201 -0
- just_bash/interpreter/builtins/source.py +136 -0
- just_bash/interpreter/builtins/test.py +290 -0
- just_bash/interpreter/builtins/unset.py +53 -0
- just_bash/interpreter/conditionals.py +387 -0
- just_bash/interpreter/control_flow.py +381 -0
- just_bash/interpreter/errors.py +116 -0
- just_bash/interpreter/expansion.py +1156 -0
- just_bash/interpreter/interpreter.py +813 -0
- just_bash/interpreter/types.py +134 -0
- just_bash/network/__init__.py +1 -0
- just_bash/parser/__init__.py +39 -0
- just_bash/parser/lexer.py +948 -0
- just_bash/parser/parser.py +2162 -0
- just_bash/py.typed +0 -0
- just_bash/query_engine/__init__.py +83 -0
- just_bash/query_engine/builtins/__init__.py +1283 -0
- just_bash/query_engine/evaluator.py +578 -0
- just_bash/query_engine/parser.py +525 -0
- just_bash/query_engine/tokenizer.py +329 -0
- just_bash/query_engine/types.py +373 -0
- just_bash/types.py +180 -0
- just_bash-0.1.5.dist-info/METADATA +410 -0
- just_bash-0.1.5.dist-info/RECORD +193 -0
- 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)
|