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
just_bash/fs/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Filesystem implementations for just-bash."""
|
|
2
|
+
|
|
3
|
+
from .in_memory_fs import (
|
|
4
|
+
InMemoryFs,
|
|
5
|
+
FileEntry,
|
|
6
|
+
DirectoryEntry,
|
|
7
|
+
SymlinkEntry,
|
|
8
|
+
FsEntry,
|
|
9
|
+
DirentEntry,
|
|
10
|
+
)
|
|
11
|
+
from .read_write_fs import ReadWriteFs, ReadWriteFsOptions
|
|
12
|
+
from .overlay_fs import OverlayFs, OverlayFsOptions
|
|
13
|
+
from .mountable_fs import MountableFs, MountableFsOptions, MountConfig
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"InMemoryFs",
|
|
17
|
+
"FileEntry",
|
|
18
|
+
"DirectoryEntry",
|
|
19
|
+
"SymlinkEntry",
|
|
20
|
+
"FsEntry",
|
|
21
|
+
"DirentEntry",
|
|
22
|
+
"ReadWriteFs",
|
|
23
|
+
"ReadWriteFsOptions",
|
|
24
|
+
"OverlayFs",
|
|
25
|
+
"OverlayFsOptions",
|
|
26
|
+
"MountableFs",
|
|
27
|
+
"MountableFsOptions",
|
|
28
|
+
"MountConfig",
|
|
29
|
+
]
|
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-Memory Filesystem Implementation
|
|
3
|
+
|
|
4
|
+
A complete virtual filesystem that stores all files and directories in memory.
|
|
5
|
+
Designed for sandboxed execution without touching the real filesystem.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Union, Optional, Literal
|
|
13
|
+
from ..types import FsStat
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FileEntry:
|
|
18
|
+
"""A file in the virtual filesystem."""
|
|
19
|
+
|
|
20
|
+
type: Literal["file"] = "file"
|
|
21
|
+
content: bytes = b""
|
|
22
|
+
mode: int = 0o644
|
|
23
|
+
mtime: float = field(default_factory=time.time)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DirectoryEntry:
|
|
28
|
+
"""A directory in the virtual filesystem."""
|
|
29
|
+
|
|
30
|
+
type: Literal["directory"] = "directory"
|
|
31
|
+
mode: int = 0o755
|
|
32
|
+
mtime: float = field(default_factory=time.time)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SymlinkEntry:
|
|
37
|
+
"""A symbolic link in the virtual filesystem."""
|
|
38
|
+
|
|
39
|
+
type: Literal["symlink"] = "symlink"
|
|
40
|
+
target: str = ""
|
|
41
|
+
mode: int = 0o777
|
|
42
|
+
mtime: float = field(default_factory=time.time)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
FsEntry = Union[FileEntry, DirectoryEntry, SymlinkEntry]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class DirentEntry:
|
|
50
|
+
"""Directory entry information."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
is_file: bool = False
|
|
54
|
+
is_directory: bool = False
|
|
55
|
+
is_symbolic_link: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InMemoryFs:
|
|
59
|
+
"""In-memory filesystem implementation."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, initial_files: dict[str, str | bytes] | None = None) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Initialize the filesystem.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
initial_files: Optional dict mapping paths to file contents
|
|
67
|
+
"""
|
|
68
|
+
self._data: dict[str, FsEntry] = {}
|
|
69
|
+
|
|
70
|
+
# Create root directory
|
|
71
|
+
self._data["/"] = DirectoryEntry()
|
|
72
|
+
|
|
73
|
+
# Create default directory structure
|
|
74
|
+
self._create_default_structure()
|
|
75
|
+
|
|
76
|
+
# Add initial files
|
|
77
|
+
if initial_files:
|
|
78
|
+
for path, content in initial_files.items():
|
|
79
|
+
self._write_file_sync(path, content)
|
|
80
|
+
|
|
81
|
+
def _create_default_structure(self) -> None:
|
|
82
|
+
"""Create default Unix-like directory structure."""
|
|
83
|
+
default_dirs = [
|
|
84
|
+
"/home",
|
|
85
|
+
"/home/user",
|
|
86
|
+
"/tmp",
|
|
87
|
+
"/bin",
|
|
88
|
+
"/usr",
|
|
89
|
+
"/usr/bin",
|
|
90
|
+
]
|
|
91
|
+
for dir_path in default_dirs:
|
|
92
|
+
self._mkdir_sync(dir_path, recursive=True)
|
|
93
|
+
|
|
94
|
+
def _normalize_path(self, path: str) -> str:
|
|
95
|
+
"""Normalize a path (resolve ., .., trailing slashes)."""
|
|
96
|
+
if not path or path == "/":
|
|
97
|
+
return "/"
|
|
98
|
+
|
|
99
|
+
# Remove trailing slash
|
|
100
|
+
if path.endswith("/") and path != "/":
|
|
101
|
+
path = path[:-1]
|
|
102
|
+
|
|
103
|
+
# Ensure starts with /
|
|
104
|
+
if not path.startswith("/"):
|
|
105
|
+
path = "/" + path
|
|
106
|
+
|
|
107
|
+
# Resolve . and ..
|
|
108
|
+
parts = path.split("/")
|
|
109
|
+
resolved: list[str] = []
|
|
110
|
+
|
|
111
|
+
for part in parts:
|
|
112
|
+
if part == "" or part == ".":
|
|
113
|
+
continue
|
|
114
|
+
elif part == "..":
|
|
115
|
+
if resolved:
|
|
116
|
+
resolved.pop()
|
|
117
|
+
else:
|
|
118
|
+
resolved.append(part)
|
|
119
|
+
|
|
120
|
+
return "/" + "/".join(resolved) if resolved else "/"
|
|
121
|
+
|
|
122
|
+
def _dirname(self, path: str) -> str:
|
|
123
|
+
"""Get the directory name of a path."""
|
|
124
|
+
normalized = self._normalize_path(path)
|
|
125
|
+
if normalized == "/":
|
|
126
|
+
return "/"
|
|
127
|
+
last_slash = normalized.rfind("/")
|
|
128
|
+
return "/" if last_slash == 0 else normalized[:last_slash]
|
|
129
|
+
|
|
130
|
+
def _basename(self, path: str) -> str:
|
|
131
|
+
"""Get the base name of a path."""
|
|
132
|
+
normalized = self._normalize_path(path)
|
|
133
|
+
if normalized == "/":
|
|
134
|
+
return ""
|
|
135
|
+
return normalized.rsplit("/", 1)[-1]
|
|
136
|
+
|
|
137
|
+
def _ensure_parent_dirs(self, path: str) -> None:
|
|
138
|
+
"""Ensure all parent directories exist."""
|
|
139
|
+
dir_path = self._dirname(path)
|
|
140
|
+
if dir_path == "/":
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if dir_path not in self._data:
|
|
144
|
+
self._ensure_parent_dirs(dir_path)
|
|
145
|
+
self._data[dir_path] = DirectoryEntry()
|
|
146
|
+
|
|
147
|
+
def _resolve_symlink(self, symlink_path: str, target: str) -> str:
|
|
148
|
+
"""Resolve a symlink target to an absolute path."""
|
|
149
|
+
if target.startswith("/"):
|
|
150
|
+
return self._normalize_path(target)
|
|
151
|
+
# Relative target: resolve from symlink's directory
|
|
152
|
+
dir_path = self._dirname(symlink_path)
|
|
153
|
+
if dir_path == "/":
|
|
154
|
+
return self._normalize_path("/" + target)
|
|
155
|
+
return self._normalize_path(dir_path + "/" + target)
|
|
156
|
+
|
|
157
|
+
def _resolve_path_with_symlinks(self, path: str, max_loops: int = 40) -> str:
|
|
158
|
+
"""
|
|
159
|
+
Resolve all symlinks in a path, including intermediate components.
|
|
160
|
+
"""
|
|
161
|
+
normalized = self._normalize_path(path)
|
|
162
|
+
if normalized == "/":
|
|
163
|
+
return "/"
|
|
164
|
+
|
|
165
|
+
parts = normalized[1:].split("/") # Skip leading /
|
|
166
|
+
resolved_path = ""
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
|
|
169
|
+
for part in parts:
|
|
170
|
+
resolved_path = f"{resolved_path}/{part}"
|
|
171
|
+
|
|
172
|
+
# Check if this path component is a symlink
|
|
173
|
+
entry = self._data.get(resolved_path)
|
|
174
|
+
loop_count = 0
|
|
175
|
+
|
|
176
|
+
while entry and entry.type == "symlink" and loop_count < max_loops:
|
|
177
|
+
if resolved_path in seen:
|
|
178
|
+
raise OSError(
|
|
179
|
+
f"ELOOP: too many levels of symbolic links, open '{path}'"
|
|
180
|
+
)
|
|
181
|
+
seen.add(resolved_path)
|
|
182
|
+
resolved_path = self._resolve_symlink(resolved_path, entry.target)
|
|
183
|
+
entry = self._data.get(resolved_path)
|
|
184
|
+
loop_count += 1
|
|
185
|
+
|
|
186
|
+
if loop_count >= max_loops:
|
|
187
|
+
raise OSError(
|
|
188
|
+
f"ELOOP: too many levels of symbolic links, open '{path}'"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return resolved_path
|
|
192
|
+
|
|
193
|
+
def _resolve_intermediate_symlinks(self, path: str, max_loops: int = 40) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Resolve symlinks in intermediate path components only (not the final).
|
|
196
|
+
Used by lstat which should not follow the final symlink.
|
|
197
|
+
"""
|
|
198
|
+
normalized = self._normalize_path(path)
|
|
199
|
+
if normalized == "/":
|
|
200
|
+
return "/"
|
|
201
|
+
|
|
202
|
+
parts = normalized[1:].split("/")
|
|
203
|
+
if len(parts) <= 1:
|
|
204
|
+
return normalized
|
|
205
|
+
|
|
206
|
+
resolved_path = ""
|
|
207
|
+
seen: set[str] = set()
|
|
208
|
+
|
|
209
|
+
# Process all but the last component
|
|
210
|
+
for i in range(len(parts) - 1):
|
|
211
|
+
part = parts[i]
|
|
212
|
+
resolved_path = f"{resolved_path}/{part}"
|
|
213
|
+
|
|
214
|
+
entry = self._data.get(resolved_path)
|
|
215
|
+
loop_count = 0
|
|
216
|
+
|
|
217
|
+
while entry and entry.type == "symlink" and loop_count < max_loops:
|
|
218
|
+
if resolved_path in seen:
|
|
219
|
+
raise OSError(
|
|
220
|
+
f"ELOOP: too many levels of symbolic links, lstat '{path}'"
|
|
221
|
+
)
|
|
222
|
+
seen.add(resolved_path)
|
|
223
|
+
resolved_path = self._resolve_symlink(resolved_path, entry.target)
|
|
224
|
+
entry = self._data.get(resolved_path)
|
|
225
|
+
loop_count += 1
|
|
226
|
+
|
|
227
|
+
if loop_count >= max_loops:
|
|
228
|
+
raise OSError(
|
|
229
|
+
f"ELOOP: too many levels of symbolic links, lstat '{path}'"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Append the final component without resolving
|
|
233
|
+
return f"{resolved_path}/{parts[-1]}"
|
|
234
|
+
|
|
235
|
+
# =========================================================================
|
|
236
|
+
# Sync methods (internal)
|
|
237
|
+
# =========================================================================
|
|
238
|
+
|
|
239
|
+
def _write_file_sync(
|
|
240
|
+
self,
|
|
241
|
+
path: str,
|
|
242
|
+
content: str | bytes,
|
|
243
|
+
encoding: str = "utf-8",
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Synchronously write a file."""
|
|
246
|
+
normalized = self._normalize_path(path)
|
|
247
|
+
self._ensure_parent_dirs(normalized)
|
|
248
|
+
|
|
249
|
+
# Convert content to bytes
|
|
250
|
+
if isinstance(content, str):
|
|
251
|
+
content_bytes = content.encode(encoding)
|
|
252
|
+
else:
|
|
253
|
+
content_bytes = content
|
|
254
|
+
|
|
255
|
+
self._data[normalized] = FileEntry(content=content_bytes)
|
|
256
|
+
|
|
257
|
+
def _mkdir_sync(self, path: str, recursive: bool = False) -> None:
|
|
258
|
+
"""Synchronously create a directory."""
|
|
259
|
+
normalized = self._normalize_path(path)
|
|
260
|
+
|
|
261
|
+
if normalized in self._data:
|
|
262
|
+
entry = self._data[normalized]
|
|
263
|
+
if entry.type == "file":
|
|
264
|
+
raise OSError(f"EEXIST: file already exists, mkdir '{path}'")
|
|
265
|
+
if not recursive:
|
|
266
|
+
raise OSError(f"EEXIST: directory already exists, mkdir '{path}'")
|
|
267
|
+
return # With recursive, silently succeed if directory exists
|
|
268
|
+
|
|
269
|
+
parent = self._dirname(normalized)
|
|
270
|
+
if parent != "/" and parent not in self._data:
|
|
271
|
+
if recursive:
|
|
272
|
+
self._mkdir_sync(parent, recursive=True)
|
|
273
|
+
else:
|
|
274
|
+
raise OSError(f"ENOENT: no such file or directory, mkdir '{path}'")
|
|
275
|
+
|
|
276
|
+
self._data[normalized] = DirectoryEntry()
|
|
277
|
+
|
|
278
|
+
# =========================================================================
|
|
279
|
+
# Async public API
|
|
280
|
+
# =========================================================================
|
|
281
|
+
|
|
282
|
+
async def read_file(self, path: str, encoding: str = "utf-8") -> str:
|
|
283
|
+
"""Read file contents as string."""
|
|
284
|
+
buffer = await self.read_file_bytes(path)
|
|
285
|
+
return buffer.decode(encoding)
|
|
286
|
+
|
|
287
|
+
async def read_file_bytes(self, path: str) -> bytes:
|
|
288
|
+
"""Read file contents as bytes."""
|
|
289
|
+
resolved_path = self._resolve_path_with_symlinks(path)
|
|
290
|
+
entry = self._data.get(resolved_path)
|
|
291
|
+
|
|
292
|
+
if entry is None:
|
|
293
|
+
raise FileNotFoundError(f"ENOENT: no such file or directory, open '{path}'")
|
|
294
|
+
if entry.type != "file":
|
|
295
|
+
raise IsADirectoryError(
|
|
296
|
+
f"EISDIR: illegal operation on a directory, read '{path}'"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return entry.content
|
|
300
|
+
|
|
301
|
+
async def write_file(
|
|
302
|
+
self,
|
|
303
|
+
path: str,
|
|
304
|
+
content: str | bytes,
|
|
305
|
+
encoding: str = "utf-8",
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Write content to file."""
|
|
308
|
+
self._write_file_sync(path, content, encoding)
|
|
309
|
+
|
|
310
|
+
async def append_file(
|
|
311
|
+
self,
|
|
312
|
+
path: str,
|
|
313
|
+
content: str | bytes,
|
|
314
|
+
encoding: str = "utf-8",
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Append content to file."""
|
|
317
|
+
normalized = self._normalize_path(path)
|
|
318
|
+
existing = self._data.get(normalized)
|
|
319
|
+
|
|
320
|
+
if existing and existing.type == "directory":
|
|
321
|
+
raise IsADirectoryError(
|
|
322
|
+
f"EISDIR: illegal operation on a directory, write '{path}'"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Convert content to bytes
|
|
326
|
+
if isinstance(content, str):
|
|
327
|
+
new_bytes = content.encode(encoding)
|
|
328
|
+
else:
|
|
329
|
+
new_bytes = content
|
|
330
|
+
|
|
331
|
+
if existing and existing.type == "file":
|
|
332
|
+
combined = existing.content + new_bytes
|
|
333
|
+
self._data[normalized] = FileEntry(
|
|
334
|
+
content=combined,
|
|
335
|
+
mode=existing.mode,
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
self._write_file_sync(path, content, encoding)
|
|
339
|
+
|
|
340
|
+
async def exists(self, path: str) -> bool:
|
|
341
|
+
"""Check if path exists."""
|
|
342
|
+
try:
|
|
343
|
+
resolved_path = self._resolve_path_with_symlinks(path)
|
|
344
|
+
return resolved_path in self._data
|
|
345
|
+
except OSError:
|
|
346
|
+
# Path resolution failed (e.g., broken symlink)
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
async def is_file(self, path: str) -> bool:
|
|
350
|
+
"""Check if path is a file."""
|
|
351
|
+
try:
|
|
352
|
+
resolved_path = self._resolve_path_with_symlinks(path)
|
|
353
|
+
entry = self._data.get(resolved_path)
|
|
354
|
+
return entry is not None and entry.type == "file"
|
|
355
|
+
except OSError:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
async def is_directory(self, path: str) -> bool:
|
|
359
|
+
"""Check if path is a directory."""
|
|
360
|
+
try:
|
|
361
|
+
resolved_path = self._resolve_path_with_symlinks(path)
|
|
362
|
+
entry = self._data.get(resolved_path)
|
|
363
|
+
return entry is not None and entry.type == "directory"
|
|
364
|
+
except OSError:
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
async def stat(self, path: str) -> FsStat:
|
|
368
|
+
"""Get file/directory stats (follows symlinks)."""
|
|
369
|
+
resolved_path = self._resolve_path_with_symlinks(path)
|
|
370
|
+
entry = self._data.get(resolved_path)
|
|
371
|
+
|
|
372
|
+
if entry is None:
|
|
373
|
+
raise FileNotFoundError(f"ENOENT: no such file or directory, stat '{path}'")
|
|
374
|
+
|
|
375
|
+
size = 0
|
|
376
|
+
if entry.type == "file":
|
|
377
|
+
size = len(entry.content)
|
|
378
|
+
|
|
379
|
+
return FsStat(
|
|
380
|
+
is_file=entry.type == "file",
|
|
381
|
+
is_directory=entry.type == "directory",
|
|
382
|
+
is_symbolic_link=False, # stat follows symlinks
|
|
383
|
+
mode=entry.mode,
|
|
384
|
+
size=size,
|
|
385
|
+
mtime=entry.mtime,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
async def lstat(self, path: str) -> FsStat:
|
|
389
|
+
"""Get file/directory stats (does not follow final symlink)."""
|
|
390
|
+
resolved_path = self._resolve_intermediate_symlinks(path)
|
|
391
|
+
entry = self._data.get(resolved_path)
|
|
392
|
+
|
|
393
|
+
if entry is None:
|
|
394
|
+
raise FileNotFoundError(
|
|
395
|
+
f"ENOENT: no such file or directory, lstat '{path}'"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if entry.type == "symlink":
|
|
399
|
+
return FsStat(
|
|
400
|
+
is_file=False,
|
|
401
|
+
is_directory=False,
|
|
402
|
+
is_symbolic_link=True,
|
|
403
|
+
mode=entry.mode,
|
|
404
|
+
size=len(entry.target),
|
|
405
|
+
mtime=entry.mtime,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
size = 0
|
|
409
|
+
if entry.type == "file":
|
|
410
|
+
size = len(entry.content)
|
|
411
|
+
|
|
412
|
+
return FsStat(
|
|
413
|
+
is_file=entry.type == "file",
|
|
414
|
+
is_directory=entry.type == "directory",
|
|
415
|
+
is_symbolic_link=False,
|
|
416
|
+
mode=entry.mode,
|
|
417
|
+
size=size,
|
|
418
|
+
mtime=entry.mtime,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def mkdir(self, path: str, recursive: bool = False) -> None:
|
|
422
|
+
"""Create a directory."""
|
|
423
|
+
self._mkdir_sync(path, recursive=recursive)
|
|
424
|
+
|
|
425
|
+
async def readdir(self, path: str) -> list[str]:
|
|
426
|
+
"""List directory contents."""
|
|
427
|
+
entries = await self.readdir_with_file_types(path)
|
|
428
|
+
return [e.name for e in entries]
|
|
429
|
+
|
|
430
|
+
async def readdir_with_file_types(self, path: str) -> list[DirentEntry]:
|
|
431
|
+
"""List directory contents with type information."""
|
|
432
|
+
normalized = self._normalize_path(path)
|
|
433
|
+
entry = self._data.get(normalized)
|
|
434
|
+
|
|
435
|
+
if entry is None:
|
|
436
|
+
raise FileNotFoundError(
|
|
437
|
+
f"ENOENT: no such file or directory, scandir '{path}'"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Follow symlinks to get to the actual directory
|
|
441
|
+
seen: set[str] = set()
|
|
442
|
+
while entry and entry.type == "symlink":
|
|
443
|
+
if normalized in seen:
|
|
444
|
+
raise OSError(
|
|
445
|
+
f"ELOOP: too many levels of symbolic links, scandir '{path}'"
|
|
446
|
+
)
|
|
447
|
+
seen.add(normalized)
|
|
448
|
+
normalized = self._resolve_symlink(normalized, entry.target)
|
|
449
|
+
entry = self._data.get(normalized)
|
|
450
|
+
|
|
451
|
+
if entry is None:
|
|
452
|
+
raise FileNotFoundError(
|
|
453
|
+
f"ENOENT: no such file or directory, scandir '{path}'"
|
|
454
|
+
)
|
|
455
|
+
if entry.type != "directory":
|
|
456
|
+
raise NotADirectoryError(f"ENOTDIR: not a directory, scandir '{path}'")
|
|
457
|
+
|
|
458
|
+
prefix = "/" if normalized == "/" else f"{normalized}/"
|
|
459
|
+
entries_map: dict[str, DirentEntry] = {}
|
|
460
|
+
|
|
461
|
+
for p, fs_entry in self._data.items():
|
|
462
|
+
if p == normalized:
|
|
463
|
+
continue
|
|
464
|
+
if p.startswith(prefix):
|
|
465
|
+
rest = p[len(prefix) :]
|
|
466
|
+
name = rest.split("/")[0]
|
|
467
|
+
# Only add direct children
|
|
468
|
+
if name and "/" not in rest[len(name) :] and name not in entries_map:
|
|
469
|
+
entries_map[name] = DirentEntry(
|
|
470
|
+
name=name,
|
|
471
|
+
is_file=fs_entry.type == "file",
|
|
472
|
+
is_directory=fs_entry.type == "directory",
|
|
473
|
+
is_symbolic_link=fs_entry.type == "symlink",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Sort by name
|
|
477
|
+
return sorted(entries_map.values(), key=lambda e: e.name)
|
|
478
|
+
|
|
479
|
+
async def rm(
|
|
480
|
+
self, path: str, recursive: bool = False, force: bool = False
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Remove a file or directory."""
|
|
483
|
+
normalized = self._normalize_path(path)
|
|
484
|
+
entry = self._data.get(normalized)
|
|
485
|
+
|
|
486
|
+
if entry is None:
|
|
487
|
+
if force:
|
|
488
|
+
return
|
|
489
|
+
raise FileNotFoundError(f"ENOENT: no such file or directory, rm '{path}'")
|
|
490
|
+
|
|
491
|
+
if entry.type == "directory":
|
|
492
|
+
children = await self.readdir(normalized)
|
|
493
|
+
if children:
|
|
494
|
+
if not recursive:
|
|
495
|
+
raise OSError(f"ENOTEMPTY: directory not empty, rm '{path}'")
|
|
496
|
+
for child in children:
|
|
497
|
+
child_path = (
|
|
498
|
+
f"/{child}" if normalized == "/" else f"{normalized}/{child}"
|
|
499
|
+
)
|
|
500
|
+
await self.rm(child_path, recursive=recursive, force=force)
|
|
501
|
+
|
|
502
|
+
del self._data[normalized]
|
|
503
|
+
|
|
504
|
+
async def cp(self, src: str, dest: str, recursive: bool = False) -> None:
|
|
505
|
+
"""Copy a file or directory."""
|
|
506
|
+
src_norm = self._normalize_path(src)
|
|
507
|
+
dest_norm = self._normalize_path(dest)
|
|
508
|
+
src_entry = self._data.get(src_norm)
|
|
509
|
+
|
|
510
|
+
if src_entry is None:
|
|
511
|
+
raise FileNotFoundError(f"ENOENT: no such file or directory, cp '{src}'")
|
|
512
|
+
|
|
513
|
+
if src_entry.type == "file":
|
|
514
|
+
self._ensure_parent_dirs(dest_norm)
|
|
515
|
+
self._data[dest_norm] = FileEntry(
|
|
516
|
+
content=src_entry.content,
|
|
517
|
+
mode=src_entry.mode,
|
|
518
|
+
)
|
|
519
|
+
elif src_entry.type == "directory":
|
|
520
|
+
if not recursive:
|
|
521
|
+
raise IsADirectoryError(f"EISDIR: is a directory, cp '{src}'")
|
|
522
|
+
await self.mkdir(dest_norm, recursive=True)
|
|
523
|
+
children = await self.readdir(src_norm)
|
|
524
|
+
for child in children:
|
|
525
|
+
src_child = (
|
|
526
|
+
f"/{child}" if src_norm == "/" else f"{src_norm}/{child}"
|
|
527
|
+
)
|
|
528
|
+
dest_child = (
|
|
529
|
+
f"/{child}" if dest_norm == "/" else f"{dest_norm}/{child}"
|
|
530
|
+
)
|
|
531
|
+
await self.cp(src_child, dest_child, recursive=recursive)
|
|
532
|
+
|
|
533
|
+
async def mv(self, src: str, dest: str) -> None:
|
|
534
|
+
"""Move a file or directory."""
|
|
535
|
+
await self.cp(src, dest, recursive=True)
|
|
536
|
+
await self.rm(src, recursive=True)
|
|
537
|
+
|
|
538
|
+
async def chmod(self, path: str, mode: int) -> None:
|
|
539
|
+
"""Change file/directory permissions."""
|
|
540
|
+
normalized = self._normalize_path(path)
|
|
541
|
+
entry = self._data.get(normalized)
|
|
542
|
+
|
|
543
|
+
if entry is None:
|
|
544
|
+
raise FileNotFoundError(
|
|
545
|
+
f"ENOENT: no such file or directory, chmod '{path}'"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Create a new entry with updated mode (since FsEntry is a dataclass)
|
|
549
|
+
if entry.type == "file":
|
|
550
|
+
self._data[normalized] = FileEntry(
|
|
551
|
+
content=entry.content, mode=mode, mtime=entry.mtime
|
|
552
|
+
)
|
|
553
|
+
elif entry.type == "directory":
|
|
554
|
+
self._data[normalized] = DirectoryEntry(mode=mode, mtime=entry.mtime)
|
|
555
|
+
elif entry.type == "symlink":
|
|
556
|
+
self._data[normalized] = SymlinkEntry(
|
|
557
|
+
target=entry.target, mode=mode, mtime=entry.mtime
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
async def symlink(self, target: str, link_path: str) -> None:
|
|
561
|
+
"""Create a symbolic link."""
|
|
562
|
+
normalized = self._normalize_path(link_path)
|
|
563
|
+
|
|
564
|
+
if normalized in self._data:
|
|
565
|
+
raise FileExistsError(f"EEXIST: file already exists, symlink '{link_path}'")
|
|
566
|
+
|
|
567
|
+
self._ensure_parent_dirs(normalized)
|
|
568
|
+
self._data[normalized] = SymlinkEntry(target=target)
|
|
569
|
+
|
|
570
|
+
async def link(self, existing_path: str, new_path: str) -> None:
|
|
571
|
+
"""Create a hard link."""
|
|
572
|
+
existing_norm = self._normalize_path(existing_path)
|
|
573
|
+
new_norm = self._normalize_path(new_path)
|
|
574
|
+
|
|
575
|
+
entry = self._data.get(existing_norm)
|
|
576
|
+
if entry is None:
|
|
577
|
+
raise FileNotFoundError(
|
|
578
|
+
f"ENOENT: no such file or directory, link '{existing_path}'"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if entry.type != "file":
|
|
582
|
+
raise PermissionError(
|
|
583
|
+
f"EPERM: operation not permitted, link '{existing_path}'"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if new_norm in self._data:
|
|
587
|
+
raise FileExistsError(f"EEXIST: file already exists, link '{new_path}'")
|
|
588
|
+
|
|
589
|
+
self._ensure_parent_dirs(new_norm)
|
|
590
|
+
# For hard links, we create a copy (simulating inode sharing)
|
|
591
|
+
self._data[new_norm] = FileEntry(
|
|
592
|
+
content=entry.content,
|
|
593
|
+
mode=entry.mode,
|
|
594
|
+
mtime=entry.mtime,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
async def readlink(self, path: str) -> str:
|
|
598
|
+
"""Read the target of a symbolic link."""
|
|
599
|
+
normalized = self._normalize_path(path)
|
|
600
|
+
entry = self._data.get(normalized)
|
|
601
|
+
|
|
602
|
+
if entry is None:
|
|
603
|
+
raise FileNotFoundError(
|
|
604
|
+
f"ENOENT: no such file or directory, readlink '{path}'"
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if entry.type != "symlink":
|
|
608
|
+
raise OSError(f"EINVAL: invalid argument, readlink '{path}'")
|
|
609
|
+
|
|
610
|
+
return entry.target
|
|
611
|
+
|
|
612
|
+
def resolve_path(self, base: str, path: str) -> str:
|
|
613
|
+
"""Resolve a path relative to a base."""
|
|
614
|
+
if path.startswith("/"):
|
|
615
|
+
return self._normalize_path(path)
|
|
616
|
+
combined = f"/{path}" if base == "/" else f"{base}/{path}"
|
|
617
|
+
return self._normalize_path(combined)
|
|
618
|
+
|
|
619
|
+
def get_all_paths(self) -> list[str]:
|
|
620
|
+
"""Get all paths in the filesystem (useful for debugging/glob)."""
|
|
621
|
+
return list(self._data.keys())
|