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,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ReadWriteFs Implementation
|
|
3
|
+
|
|
4
|
+
A filesystem wrapper that provides direct access to the real filesystem.
|
|
5
|
+
All operations are delegated to the actual OS filesystem with path translation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import aiofiles # type: ignore[import-untyped]
|
|
18
|
+
import aiofiles.os # type: ignore[import-untyped]
|
|
19
|
+
|
|
20
|
+
from ..types import FsStat
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DirentEntry:
|
|
25
|
+
"""Directory entry information."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
is_file: bool = False
|
|
29
|
+
is_directory: bool = False
|
|
30
|
+
is_symbolic_link: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ReadWriteFsOptions:
|
|
35
|
+
"""Options for ReadWriteFs."""
|
|
36
|
+
|
|
37
|
+
root: str
|
|
38
|
+
"""Root directory on the real filesystem. All operations are relative to this."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReadWriteFs:
|
|
42
|
+
"""
|
|
43
|
+
Direct wrapper around the real filesystem.
|
|
44
|
+
|
|
45
|
+
Provides an IFileSystem-compatible interface that operates on actual files.
|
|
46
|
+
All virtual paths are translated to real paths under the configured root.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, options: ReadWriteFsOptions) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initialize the filesystem.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
options: Configuration options including the root directory
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
FileNotFoundError: If root directory does not exist
|
|
58
|
+
NotADirectoryError: If root is not a directory
|
|
59
|
+
"""
|
|
60
|
+
root_path = Path(options.root)
|
|
61
|
+
|
|
62
|
+
if not root_path.exists():
|
|
63
|
+
raise FileNotFoundError(
|
|
64
|
+
f"ENOENT: no such file or directory, root '{options.root}'"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not root_path.is_dir():
|
|
68
|
+
raise NotADirectoryError(
|
|
69
|
+
f"ENOTDIR: not a directory, root '{options.root}'"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._root = root_path.resolve()
|
|
73
|
+
|
|
74
|
+
def _normalize_path(self, path: str) -> str:
|
|
75
|
+
"""Normalize a virtual path (resolve ., .., trailing slashes)."""
|
|
76
|
+
if not path or path == "/":
|
|
77
|
+
return "/"
|
|
78
|
+
|
|
79
|
+
# Remove trailing slash
|
|
80
|
+
if path.endswith("/") and path != "/":
|
|
81
|
+
path = path[:-1]
|
|
82
|
+
|
|
83
|
+
# Ensure starts with /
|
|
84
|
+
if not path.startswith("/"):
|
|
85
|
+
path = "/" + path
|
|
86
|
+
|
|
87
|
+
# Resolve . and ..
|
|
88
|
+
parts = path.split("/")
|
|
89
|
+
resolved: list[str] = []
|
|
90
|
+
|
|
91
|
+
for part in parts:
|
|
92
|
+
if part == "" or part == ".":
|
|
93
|
+
continue
|
|
94
|
+
elif part == "..":
|
|
95
|
+
if resolved:
|
|
96
|
+
resolved.pop()
|
|
97
|
+
else:
|
|
98
|
+
resolved.append(part)
|
|
99
|
+
|
|
100
|
+
return "/" + "/".join(resolved) if resolved else "/"
|
|
101
|
+
|
|
102
|
+
def _to_real_path(self, virtual_path: str) -> Path:
|
|
103
|
+
"""Convert a virtual path to a real filesystem path."""
|
|
104
|
+
normalized = self._normalize_path(virtual_path)
|
|
105
|
+
|
|
106
|
+
if normalized == "/":
|
|
107
|
+
return self._root
|
|
108
|
+
|
|
109
|
+
# Strip leading / and join with root
|
|
110
|
+
relative = normalized[1:] # Remove leading /
|
|
111
|
+
return self._root / relative
|
|
112
|
+
|
|
113
|
+
def _dirname(self, path: str) -> str:
|
|
114
|
+
"""Get the directory name of a path."""
|
|
115
|
+
normalized = self._normalize_path(path)
|
|
116
|
+
if normalized == "/":
|
|
117
|
+
return "/"
|
|
118
|
+
last_slash = normalized.rfind("/")
|
|
119
|
+
return "/" if last_slash == 0 else normalized[:last_slash]
|
|
120
|
+
|
|
121
|
+
def _ensure_parent_dirs(self, path: str) -> None:
|
|
122
|
+
"""Ensure all parent directories exist on the real filesystem."""
|
|
123
|
+
real_path = self._to_real_path(path)
|
|
124
|
+
real_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
async def read_file(self, path: str, encoding: str = "utf-8") -> str:
|
|
127
|
+
"""Read file contents as string."""
|
|
128
|
+
real_path = self._to_real_path(path)
|
|
129
|
+
|
|
130
|
+
if not real_path.exists():
|
|
131
|
+
raise FileNotFoundError(
|
|
132
|
+
f"ENOENT: no such file or directory, open '{path}'"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if real_path.is_dir():
|
|
136
|
+
raise IsADirectoryError(
|
|
137
|
+
f"EISDIR: illegal operation on a directory, read '{path}'"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async with aiofiles.open(real_path, "r", encoding=encoding) as f:
|
|
141
|
+
content: str = await f.read()
|
|
142
|
+
return content
|
|
143
|
+
|
|
144
|
+
async def read_file_bytes(self, path: str) -> bytes:
|
|
145
|
+
"""Read file contents as bytes."""
|
|
146
|
+
real_path = self._to_real_path(path)
|
|
147
|
+
|
|
148
|
+
if not real_path.exists():
|
|
149
|
+
raise FileNotFoundError(
|
|
150
|
+
f"ENOENT: no such file or directory, open '{path}'"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if real_path.is_dir():
|
|
154
|
+
raise IsADirectoryError(
|
|
155
|
+
f"EISDIR: illegal operation on a directory, read '{path}'"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async with aiofiles.open(real_path, "rb") as f:
|
|
159
|
+
content: bytes = await f.read()
|
|
160
|
+
return content
|
|
161
|
+
|
|
162
|
+
async def write_file(
|
|
163
|
+
self,
|
|
164
|
+
path: str,
|
|
165
|
+
content: str | bytes,
|
|
166
|
+
encoding: str = "utf-8",
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Write content to file."""
|
|
169
|
+
self._ensure_parent_dirs(path)
|
|
170
|
+
real_path = self._to_real_path(path)
|
|
171
|
+
|
|
172
|
+
if isinstance(content, str):
|
|
173
|
+
async with aiofiles.open(real_path, "w", encoding=encoding) as f:
|
|
174
|
+
await f.write(content)
|
|
175
|
+
else:
|
|
176
|
+
async with aiofiles.open(real_path, "wb") as f:
|
|
177
|
+
await f.write(content)
|
|
178
|
+
|
|
179
|
+
async def append_file(
|
|
180
|
+
self,
|
|
181
|
+
path: str,
|
|
182
|
+
content: str | bytes,
|
|
183
|
+
encoding: str = "utf-8",
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Append content to file."""
|
|
186
|
+
self._ensure_parent_dirs(path)
|
|
187
|
+
real_path = self._to_real_path(path)
|
|
188
|
+
|
|
189
|
+
if isinstance(content, str):
|
|
190
|
+
async with aiofiles.open(real_path, "a", encoding=encoding) as f:
|
|
191
|
+
await f.write(content)
|
|
192
|
+
else:
|
|
193
|
+
async with aiofiles.open(real_path, "ab") as f:
|
|
194
|
+
await f.write(content)
|
|
195
|
+
|
|
196
|
+
async def exists(self, path: str) -> bool:
|
|
197
|
+
"""Check if path exists."""
|
|
198
|
+
real_path = self._to_real_path(path)
|
|
199
|
+
return real_path.exists()
|
|
200
|
+
|
|
201
|
+
async def is_file(self, path: str) -> bool:
|
|
202
|
+
"""Check if path is a file."""
|
|
203
|
+
real_path = self._to_real_path(path)
|
|
204
|
+
return real_path.is_file()
|
|
205
|
+
|
|
206
|
+
async def is_directory(self, path: str) -> bool:
|
|
207
|
+
"""Check if path is a directory."""
|
|
208
|
+
real_path = self._to_real_path(path)
|
|
209
|
+
return real_path.is_dir()
|
|
210
|
+
|
|
211
|
+
async def stat(self, path: str) -> FsStat:
|
|
212
|
+
"""Get file/directory stats (follows symlinks)."""
|
|
213
|
+
real_path = self._to_real_path(path)
|
|
214
|
+
|
|
215
|
+
if not real_path.exists():
|
|
216
|
+
raise FileNotFoundError(
|
|
217
|
+
f"ENOENT: no such file or directory, stat '{path}'"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
stat_result = real_path.stat()
|
|
221
|
+
|
|
222
|
+
return FsStat(
|
|
223
|
+
is_file=real_path.is_file(),
|
|
224
|
+
is_directory=real_path.is_dir(),
|
|
225
|
+
is_symbolic_link=False, # stat follows symlinks
|
|
226
|
+
mode=stat_result.st_mode & 0o777,
|
|
227
|
+
size=stat_result.st_size,
|
|
228
|
+
mtime=stat_result.st_mtime,
|
|
229
|
+
nlink=stat_result.st_nlink,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def lstat(self, path: str) -> FsStat:
|
|
233
|
+
"""Get file/directory stats (does not follow final symlink)."""
|
|
234
|
+
real_path = self._to_real_path(path)
|
|
235
|
+
|
|
236
|
+
if not real_path.exists() and not real_path.is_symlink():
|
|
237
|
+
raise FileNotFoundError(
|
|
238
|
+
f"ENOENT: no such file or directory, lstat '{path}'"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
stat_result = real_path.lstat()
|
|
242
|
+
is_symlink = real_path.is_symlink()
|
|
243
|
+
|
|
244
|
+
return FsStat(
|
|
245
|
+
is_file=not is_symlink and real_path.is_file(),
|
|
246
|
+
is_directory=not is_symlink and real_path.is_dir(),
|
|
247
|
+
is_symbolic_link=is_symlink,
|
|
248
|
+
mode=stat_result.st_mode & 0o777,
|
|
249
|
+
size=stat_result.st_size,
|
|
250
|
+
mtime=stat_result.st_mtime,
|
|
251
|
+
nlink=stat_result.st_nlink,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def mkdir(self, path: str, recursive: bool = False) -> None:
|
|
255
|
+
"""Create a directory."""
|
|
256
|
+
real_path = self._to_real_path(path)
|
|
257
|
+
|
|
258
|
+
if real_path.exists():
|
|
259
|
+
if real_path.is_file():
|
|
260
|
+
raise OSError(f"EEXIST: file already exists, mkdir '{path}'")
|
|
261
|
+
if not recursive:
|
|
262
|
+
raise OSError(f"EEXIST: directory already exists, mkdir '{path}'")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
if recursive:
|
|
266
|
+
real_path.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
else:
|
|
268
|
+
if not real_path.parent.exists():
|
|
269
|
+
raise OSError(f"ENOENT: no such file or directory, mkdir '{path}'")
|
|
270
|
+
real_path.mkdir()
|
|
271
|
+
|
|
272
|
+
async def readdir(self, path: str) -> list[str]:
|
|
273
|
+
"""List directory contents."""
|
|
274
|
+
entries = await self.readdir_with_file_types(path)
|
|
275
|
+
return [e.name for e in entries]
|
|
276
|
+
|
|
277
|
+
async def readdir_with_file_types(self, path: str) -> list[DirentEntry]:
|
|
278
|
+
"""List directory contents with type information."""
|
|
279
|
+
real_path = self._to_real_path(path)
|
|
280
|
+
|
|
281
|
+
if not real_path.exists():
|
|
282
|
+
raise FileNotFoundError(
|
|
283
|
+
f"ENOENT: no such file or directory, scandir '{path}'"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if not real_path.is_dir():
|
|
287
|
+
raise NotADirectoryError(
|
|
288
|
+
f"ENOTDIR: not a directory, scandir '{path}'"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
entries: list[DirentEntry] = []
|
|
292
|
+
for item in real_path.iterdir():
|
|
293
|
+
entries.append(
|
|
294
|
+
DirentEntry(
|
|
295
|
+
name=item.name,
|
|
296
|
+
is_file=item.is_file(),
|
|
297
|
+
is_directory=item.is_dir(),
|
|
298
|
+
is_symbolic_link=item.is_symlink(),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return sorted(entries, key=lambda e: e.name)
|
|
303
|
+
|
|
304
|
+
async def rm(
|
|
305
|
+
self, path: str, recursive: bool = False, force: bool = False
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Remove a file or directory."""
|
|
308
|
+
real_path = self._to_real_path(path)
|
|
309
|
+
|
|
310
|
+
if not real_path.exists():
|
|
311
|
+
if force:
|
|
312
|
+
return
|
|
313
|
+
raise FileNotFoundError(
|
|
314
|
+
f"ENOENT: no such file or directory, rm '{path}'"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if real_path.is_dir():
|
|
318
|
+
if recursive:
|
|
319
|
+
shutil.rmtree(real_path)
|
|
320
|
+
else:
|
|
321
|
+
# Check if empty
|
|
322
|
+
if any(real_path.iterdir()):
|
|
323
|
+
raise OSError(f"ENOTEMPTY: directory not empty, rm '{path}'")
|
|
324
|
+
real_path.rmdir()
|
|
325
|
+
else:
|
|
326
|
+
real_path.unlink()
|
|
327
|
+
|
|
328
|
+
async def cp(self, src: str, dest: str, recursive: bool = False) -> None:
|
|
329
|
+
"""Copy a file or directory."""
|
|
330
|
+
src_real = self._to_real_path(src)
|
|
331
|
+
dest_real = self._to_real_path(dest)
|
|
332
|
+
|
|
333
|
+
if not src_real.exists():
|
|
334
|
+
raise FileNotFoundError(
|
|
335
|
+
f"ENOENT: no such file or directory, cp '{src}'"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if src_real.is_dir():
|
|
339
|
+
if not recursive:
|
|
340
|
+
raise IsADirectoryError(f"EISDIR: is a directory, cp '{src}'")
|
|
341
|
+
shutil.copytree(src_real, dest_real)
|
|
342
|
+
else:
|
|
343
|
+
self._ensure_parent_dirs(dest)
|
|
344
|
+
shutil.copy2(src_real, dest_real)
|
|
345
|
+
|
|
346
|
+
async def mv(self, src: str, dest: str) -> None:
|
|
347
|
+
"""Move a file or directory."""
|
|
348
|
+
src_real = self._to_real_path(src)
|
|
349
|
+
dest_real = self._to_real_path(dest)
|
|
350
|
+
|
|
351
|
+
if not src_real.exists():
|
|
352
|
+
raise FileNotFoundError(
|
|
353
|
+
f"ENOENT: no such file or directory, mv '{src}'"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
self._ensure_parent_dirs(dest)
|
|
357
|
+
shutil.move(str(src_real), str(dest_real))
|
|
358
|
+
|
|
359
|
+
async def chmod(self, path: str, mode: int) -> None:
|
|
360
|
+
"""Change file/directory permissions."""
|
|
361
|
+
real_path = self._to_real_path(path)
|
|
362
|
+
|
|
363
|
+
if not real_path.exists():
|
|
364
|
+
raise FileNotFoundError(
|
|
365
|
+
f"ENOENT: no such file or directory, chmod '{path}'"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
real_path.chmod(mode)
|
|
369
|
+
|
|
370
|
+
async def symlink(self, target: str, link_path: str) -> None:
|
|
371
|
+
"""Create a symbolic link."""
|
|
372
|
+
link_real = self._to_real_path(link_path)
|
|
373
|
+
|
|
374
|
+
if link_real.exists() or link_real.is_symlink():
|
|
375
|
+
raise FileExistsError(
|
|
376
|
+
f"EEXIST: file already exists, symlink '{link_path}'"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
self._ensure_parent_dirs(link_path)
|
|
380
|
+
|
|
381
|
+
# Convert target to real path for the symlink
|
|
382
|
+
target_real = self._to_real_path(target)
|
|
383
|
+
link_real.symlink_to(target_real)
|
|
384
|
+
|
|
385
|
+
async def link(self, existing_path: str, new_path: str) -> None:
|
|
386
|
+
"""Create a hard link."""
|
|
387
|
+
existing_real = self._to_real_path(existing_path)
|
|
388
|
+
new_real = self._to_real_path(new_path)
|
|
389
|
+
|
|
390
|
+
if not existing_real.exists():
|
|
391
|
+
raise FileNotFoundError(
|
|
392
|
+
f"ENOENT: no such file or directory, link '{existing_path}'"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if not existing_real.is_file():
|
|
396
|
+
raise PermissionError(
|
|
397
|
+
f"EPERM: operation not permitted, link '{existing_path}'"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if new_real.exists():
|
|
401
|
+
raise FileExistsError(
|
|
402
|
+
f"EEXIST: file already exists, link '{new_path}'"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
self._ensure_parent_dirs(new_path)
|
|
406
|
+
new_real.hardlink_to(existing_real)
|
|
407
|
+
|
|
408
|
+
async def readlink(self, path: str) -> str:
|
|
409
|
+
"""Read the target of a symbolic link."""
|
|
410
|
+
real_path = self._to_real_path(path)
|
|
411
|
+
|
|
412
|
+
if not real_path.exists() and not real_path.is_symlink():
|
|
413
|
+
raise FileNotFoundError(
|
|
414
|
+
f"ENOENT: no such file or directory, readlink '{path}'"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if not real_path.is_symlink():
|
|
418
|
+
raise OSError(f"EINVAL: invalid argument, readlink '{path}'")
|
|
419
|
+
|
|
420
|
+
# Get the target and convert back to virtual path
|
|
421
|
+
target_real = real_path.readlink()
|
|
422
|
+
|
|
423
|
+
# If target is relative, keep it relative
|
|
424
|
+
if not target_real.is_absolute():
|
|
425
|
+
return str(target_real)
|
|
426
|
+
|
|
427
|
+
# If target is under our root, convert to virtual path
|
|
428
|
+
try:
|
|
429
|
+
relative = target_real.relative_to(self._root)
|
|
430
|
+
return "/" + str(relative)
|
|
431
|
+
except ValueError:
|
|
432
|
+
# Target is outside root, return as-is
|
|
433
|
+
return str(target_real)
|
|
434
|
+
|
|
435
|
+
def resolve_path(self, base: str, path: str) -> str:
|
|
436
|
+
"""Resolve a path relative to a base."""
|
|
437
|
+
if path.startswith("/"):
|
|
438
|
+
return self._normalize_path(path)
|
|
439
|
+
combined = f"/{path}" if base == "/" else f"{base}/{path}"
|
|
440
|
+
return self._normalize_path(combined)
|
|
441
|
+
|
|
442
|
+
def get_all_paths(self) -> list[str]:
|
|
443
|
+
"""Get all paths in the filesystem (useful for debugging/glob)."""
|
|
444
|
+
paths: list[str] = ["/"]
|
|
445
|
+
|
|
446
|
+
for root, dirs, files in os.walk(self._root):
|
|
447
|
+
rel_root = Path(root).relative_to(self._root)
|
|
448
|
+
virtual_root = "/" + str(rel_root) if str(rel_root) != "." else ""
|
|
449
|
+
|
|
450
|
+
for d in dirs:
|
|
451
|
+
paths.append(f"{virtual_root}/{d}")
|
|
452
|
+
for f in files:
|
|
453
|
+
paths.append(f"{virtual_root}/{f}")
|
|
454
|
+
|
|
455
|
+
return paths
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Interpreter module for just-bash."""
|
|
2
|
+
|
|
3
|
+
from .interpreter import Interpreter
|
|
4
|
+
from .types import InterpreterContext, InterpreterState, ShellOptions
|
|
5
|
+
from .errors import (
|
|
6
|
+
InterpreterError,
|
|
7
|
+
ExitError,
|
|
8
|
+
ReturnError,
|
|
9
|
+
BreakError,
|
|
10
|
+
ContinueError,
|
|
11
|
+
ErrexitError,
|
|
12
|
+
NounsetError,
|
|
13
|
+
BadSubstitutionError,
|
|
14
|
+
ArithmeticError,
|
|
15
|
+
ExecutionLimitError,
|
|
16
|
+
SubshellExitError,
|
|
17
|
+
is_scope_exit_error,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Interpreter",
|
|
22
|
+
"InterpreterContext",
|
|
23
|
+
"InterpreterState",
|
|
24
|
+
"ShellOptions",
|
|
25
|
+
"InterpreterError",
|
|
26
|
+
"ExitError",
|
|
27
|
+
"ReturnError",
|
|
28
|
+
"BreakError",
|
|
29
|
+
"ContinueError",
|
|
30
|
+
"ErrexitError",
|
|
31
|
+
"NounsetError",
|
|
32
|
+
"BadSubstitutionError",
|
|
33
|
+
"ArithmeticError",
|
|
34
|
+
"ExecutionLimitError",
|
|
35
|
+
"SubshellExitError",
|
|
36
|
+
"is_scope_exit_error",
|
|
37
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Builtin commands for the shell.
|
|
2
|
+
|
|
3
|
+
These are shell builtins that need direct access to InterpreterContext
|
|
4
|
+
to modify interpreter state (environment, cwd, options, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Optional, Callable, Awaitable
|
|
8
|
+
|
|
9
|
+
from .test import handle_test, handle_bracket
|
|
10
|
+
from .cd import handle_cd
|
|
11
|
+
from .export import handle_export
|
|
12
|
+
from .set import handle_set, handle_shift
|
|
13
|
+
from .unset import handle_unset
|
|
14
|
+
from .local import handle_local
|
|
15
|
+
from .source import handle_source, handle_eval
|
|
16
|
+
from .control import handle_break, handle_continue, handle_return, handle_exit
|
|
17
|
+
from .declare import handle_declare
|
|
18
|
+
from .mapfile import handle_mapfile
|
|
19
|
+
from .let import handle_let
|
|
20
|
+
from .readonly import handle_readonly
|
|
21
|
+
from .shopt import handle_shopt
|
|
22
|
+
from .alias import handle_alias, handle_unalias
|
|
23
|
+
from .misc import (
|
|
24
|
+
handle_colon,
|
|
25
|
+
handle_true,
|
|
26
|
+
handle_false,
|
|
27
|
+
handle_type,
|
|
28
|
+
handle_command,
|
|
29
|
+
handle_builtin,
|
|
30
|
+
handle_exec,
|
|
31
|
+
handle_wait,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..types import InterpreterContext
|
|
36
|
+
from ...types import ExecResult
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Map of builtin names to their handler functions
|
|
40
|
+
BUILTINS: dict[str, Callable[["InterpreterContext", list[str]], Awaitable["ExecResult"]]] = {
|
|
41
|
+
"test": handle_test,
|
|
42
|
+
"[": handle_bracket,
|
|
43
|
+
"cd": handle_cd,
|
|
44
|
+
"export": handle_export,
|
|
45
|
+
"set": handle_set,
|
|
46
|
+
"shift": handle_shift,
|
|
47
|
+
"unset": handle_unset,
|
|
48
|
+
"local": handle_local,
|
|
49
|
+
"source": handle_source,
|
|
50
|
+
".": handle_source,
|
|
51
|
+
"eval": handle_eval,
|
|
52
|
+
"break": handle_break,
|
|
53
|
+
"continue": handle_continue,
|
|
54
|
+
"return": handle_return,
|
|
55
|
+
"exit": handle_exit,
|
|
56
|
+
"declare": handle_declare,
|
|
57
|
+
"typeset": handle_declare, # typeset is alias for declare
|
|
58
|
+
"mapfile": handle_mapfile,
|
|
59
|
+
"readarray": handle_mapfile, # readarray is alias for mapfile
|
|
60
|
+
"let": handle_let,
|
|
61
|
+
"readonly": handle_readonly,
|
|
62
|
+
"shopt": handle_shopt,
|
|
63
|
+
"alias": handle_alias,
|
|
64
|
+
"unalias": handle_unalias,
|
|
65
|
+
":": handle_colon,
|
|
66
|
+
"true": handle_true,
|
|
67
|
+
"false": handle_false,
|
|
68
|
+
"type": handle_type,
|
|
69
|
+
"command": handle_command,
|
|
70
|
+
"builtin": handle_builtin,
|
|
71
|
+
"exec": handle_exec,
|
|
72
|
+
"wait": handle_wait,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"BUILTINS",
|
|
78
|
+
"handle_test",
|
|
79
|
+
"handle_cd",
|
|
80
|
+
"handle_export",
|
|
81
|
+
"handle_set",
|
|
82
|
+
"handle_shift",
|
|
83
|
+
"handle_unset",
|
|
84
|
+
"handle_local",
|
|
85
|
+
"handle_source",
|
|
86
|
+
"handle_eval",
|
|
87
|
+
"handle_break",
|
|
88
|
+
"handle_continue",
|
|
89
|
+
"handle_return",
|
|
90
|
+
"handle_exit",
|
|
91
|
+
"handle_declare",
|
|
92
|
+
]
|