just-bash 0.1.5__py3-none-any.whl → 0.1.10__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/ast/factory.py +3 -1
- just_bash/bash.py +28 -6
- just_bash/commands/awk/awk.py +362 -17
- just_bash/commands/cat/cat.py +5 -1
- just_bash/commands/echo/echo.py +33 -1
- just_bash/commands/grep/grep.py +141 -3
- just_bash/commands/od/od.py +144 -30
- just_bash/commands/printf/printf.py +289 -87
- just_bash/commands/pwd/pwd.py +32 -2
- just_bash/commands/read/read.py +243 -64
- just_bash/commands/readlink/readlink.py +3 -9
- just_bash/commands/registry.py +32 -0
- just_bash/commands/rmdir/__init__.py +5 -0
- just_bash/commands/rmdir/rmdir.py +160 -0
- just_bash/commands/sed/sed.py +142 -31
- just_bash/commands/shuf/__init__.py +5 -0
- just_bash/commands/shuf/shuf.py +242 -0
- just_bash/commands/stat/stat.py +9 -0
- just_bash/commands/time/__init__.py +5 -0
- just_bash/commands/time/time.py +74 -0
- just_bash/commands/touch/touch.py +118 -8
- just_bash/commands/whoami/__init__.py +5 -0
- just_bash/commands/whoami/whoami.py +18 -0
- just_bash/fs/in_memory_fs.py +22 -0
- just_bash/fs/overlay_fs.py +22 -1
- just_bash/interpreter/__init__.py +1 -1
- just_bash/interpreter/builtins/__init__.py +2 -0
- just_bash/interpreter/builtins/control.py +4 -8
- just_bash/interpreter/builtins/declare.py +321 -24
- just_bash/interpreter/builtins/getopts.py +163 -0
- just_bash/interpreter/builtins/let.py +2 -2
- just_bash/interpreter/builtins/local.py +71 -5
- just_bash/interpreter/builtins/misc.py +22 -6
- just_bash/interpreter/builtins/readonly.py +38 -10
- just_bash/interpreter/builtins/set.py +58 -8
- just_bash/interpreter/builtins/test.py +136 -19
- just_bash/interpreter/builtins/unset.py +62 -10
- just_bash/interpreter/conditionals.py +29 -4
- just_bash/interpreter/control_flow.py +61 -17
- just_bash/interpreter/expansion.py +1647 -104
- just_bash/interpreter/interpreter.py +436 -69
- just_bash/interpreter/types.py +263 -2
- just_bash/parser/__init__.py +2 -0
- just_bash/parser/lexer.py +295 -26
- just_bash/parser/parser.py +523 -64
- just_bash/types.py +11 -0
- {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
- {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/RECORD +49 -40
- {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -7,11 +7,56 @@ A FILE argument that does not exist is created empty.
|
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
9
|
-c, --no-create do not create any files
|
|
10
|
+
-d, --date=DATE parse DATE and use it instead of current time
|
|
10
11
|
"""
|
|
11
12
|
|
|
13
|
+
import time
|
|
14
|
+
import re
|
|
12
15
|
from ...types import CommandContext, ExecResult
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
def parse_date(date_str: str) -> float | None:
|
|
19
|
+
"""Parse a date string and return timestamp.
|
|
20
|
+
|
|
21
|
+
Supports:
|
|
22
|
+
- YYYY-MM-DD
|
|
23
|
+
- YYYY/MM/DD
|
|
24
|
+
- YYYY-MM-DD HH:MM:SS
|
|
25
|
+
- ISO 8601 variations
|
|
26
|
+
"""
|
|
27
|
+
date_str = date_str.strip().strip("'\"")
|
|
28
|
+
|
|
29
|
+
# Try various date formats
|
|
30
|
+
patterns = [
|
|
31
|
+
# YYYY-MM-DD HH:MM:SS
|
|
32
|
+
(r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$",
|
|
33
|
+
lambda m: (int(m[1]), int(m[2]), int(m[3]), int(m[4]), int(m[5]), int(m[6]))),
|
|
34
|
+
# YYYY-MM-DD
|
|
35
|
+
(r"^(\d{4})-(\d{2})-(\d{2})$",
|
|
36
|
+
lambda m: (int(m[1]), int(m[2]), int(m[3]), 0, 0, 0)),
|
|
37
|
+
# YYYY/MM/DD
|
|
38
|
+
(r"^(\d{4})/(\d{2})/(\d{2})$",
|
|
39
|
+
lambda m: (int(m[1]), int(m[2]), int(m[3]), 0, 0, 0)),
|
|
40
|
+
# YYYY/MM/DD HH:MM:SS
|
|
41
|
+
(r"^(\d{4})/(\d{2})/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$",
|
|
42
|
+
lambda m: (int(m[1]), int(m[2]), int(m[3]), int(m[4]), int(m[5]), int(m[6]))),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for pattern, extractor in patterns:
|
|
46
|
+
match = re.match(pattern, date_str)
|
|
47
|
+
if match:
|
|
48
|
+
year, month, day, hour, minute, second = extractor(match)
|
|
49
|
+
try:
|
|
50
|
+
import calendar
|
|
51
|
+
# Create a struct_time and convert to timestamp
|
|
52
|
+
t = (year, month, day, hour, minute, second, 0, 0, -1)
|
|
53
|
+
return calendar.timegm(t)
|
|
54
|
+
except (ValueError, OverflowError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
15
60
|
class TouchCommand:
|
|
16
61
|
"""The touch command."""
|
|
17
62
|
|
|
@@ -20,6 +65,7 @@ class TouchCommand:
|
|
|
20
65
|
async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
|
|
21
66
|
"""Execute the touch command."""
|
|
22
67
|
no_create = False
|
|
68
|
+
date_time: float | None = None
|
|
23
69
|
files: list[str] = []
|
|
24
70
|
|
|
25
71
|
# Parse arguments
|
|
@@ -32,6 +78,32 @@ class TouchCommand:
|
|
|
32
78
|
elif arg.startswith("--"):
|
|
33
79
|
if arg == "--no-create":
|
|
34
80
|
no_create = True
|
|
81
|
+
elif arg.startswith("--date="):
|
|
82
|
+
date_str = arg[7:]
|
|
83
|
+
date_time = parse_date(date_str)
|
|
84
|
+
if date_time is None:
|
|
85
|
+
return ExecResult(
|
|
86
|
+
stdout="",
|
|
87
|
+
stderr=f"touch: invalid date '{date_str}'\n",
|
|
88
|
+
exit_code=1,
|
|
89
|
+
)
|
|
90
|
+
elif arg == "--date":
|
|
91
|
+
# Next arg is the date
|
|
92
|
+
if i + 1 >= len(args):
|
|
93
|
+
return ExecResult(
|
|
94
|
+
stdout="",
|
|
95
|
+
stderr="touch: option '--date' requires an argument\n",
|
|
96
|
+
exit_code=1,
|
|
97
|
+
)
|
|
98
|
+
i += 1
|
|
99
|
+
date_str = args[i]
|
|
100
|
+
date_time = parse_date(date_str)
|
|
101
|
+
if date_time is None:
|
|
102
|
+
return ExecResult(
|
|
103
|
+
stdout="",
|
|
104
|
+
stderr=f"touch: invalid date '{date_str}'\n",
|
|
105
|
+
exit_code=1,
|
|
106
|
+
)
|
|
35
107
|
else:
|
|
36
108
|
return ExecResult(
|
|
37
109
|
stdout="",
|
|
@@ -39,15 +111,49 @@ class TouchCommand:
|
|
|
39
111
|
exit_code=1,
|
|
40
112
|
)
|
|
41
113
|
elif arg.startswith("-") and arg != "-":
|
|
42
|
-
|
|
114
|
+
j = 1
|
|
115
|
+
while j < len(arg):
|
|
116
|
+
c = arg[j]
|
|
43
117
|
if c == "c":
|
|
44
118
|
no_create = True
|
|
119
|
+
elif c == "d":
|
|
120
|
+
# -d DATE: next part or next arg is the date
|
|
121
|
+
if j + 1 < len(arg):
|
|
122
|
+
# Date is rest of this arg
|
|
123
|
+
date_str = arg[j + 1:]
|
|
124
|
+
date_time = parse_date(date_str)
|
|
125
|
+
if date_time is None:
|
|
126
|
+
return ExecResult(
|
|
127
|
+
stdout="",
|
|
128
|
+
stderr=f"touch: invalid date '{date_str}'\n",
|
|
129
|
+
exit_code=1,
|
|
130
|
+
)
|
|
131
|
+
break
|
|
132
|
+
elif i + 1 < len(args):
|
|
133
|
+
# Date is next arg
|
|
134
|
+
i += 1
|
|
135
|
+
date_str = args[i]
|
|
136
|
+
date_time = parse_date(date_str)
|
|
137
|
+
if date_time is None:
|
|
138
|
+
return ExecResult(
|
|
139
|
+
stdout="",
|
|
140
|
+
stderr=f"touch: invalid date '{date_str}'\n",
|
|
141
|
+
exit_code=1,
|
|
142
|
+
)
|
|
143
|
+
break
|
|
144
|
+
else:
|
|
145
|
+
return ExecResult(
|
|
146
|
+
stdout="",
|
|
147
|
+
stderr="touch: option requires an argument -- 'd'\n",
|
|
148
|
+
exit_code=1,
|
|
149
|
+
)
|
|
45
150
|
else:
|
|
46
151
|
return ExecResult(
|
|
47
152
|
stdout="",
|
|
48
153
|
stderr=f"touch: invalid option -- '{c}'\n",
|
|
49
154
|
exit_code=1,
|
|
50
155
|
)
|
|
156
|
+
j += 1
|
|
51
157
|
else:
|
|
52
158
|
files.append(arg)
|
|
53
159
|
i += 1
|
|
@@ -62,6 +168,10 @@ class TouchCommand:
|
|
|
62
168
|
stderr = ""
|
|
63
169
|
exit_code = 0
|
|
64
170
|
|
|
171
|
+
# Use current time if no date specified
|
|
172
|
+
if date_time is None:
|
|
173
|
+
date_time = time.time()
|
|
174
|
+
|
|
65
175
|
for f in files:
|
|
66
176
|
try:
|
|
67
177
|
path = ctx.fs.resolve_path(ctx.cwd, f)
|
|
@@ -69,23 +179,23 @@ class TouchCommand:
|
|
|
69
179
|
try:
|
|
70
180
|
stat = await ctx.fs.stat(path)
|
|
71
181
|
if stat.is_directory:
|
|
72
|
-
#
|
|
73
|
-
|
|
182
|
+
# Update directory timestamp if possible
|
|
183
|
+
await ctx.fs.utimes(path, date_time, date_time)
|
|
74
184
|
continue
|
|
75
|
-
# File exists -
|
|
76
|
-
|
|
77
|
-
await ctx.fs.write_file(path, content)
|
|
185
|
+
# File exists - use utimes to update timestamp
|
|
186
|
+
await ctx.fs.utimes(path, date_time, date_time)
|
|
78
187
|
except FileNotFoundError:
|
|
79
188
|
# File doesn't exist
|
|
80
189
|
if no_create:
|
|
81
190
|
continue
|
|
82
|
-
# Create empty file
|
|
191
|
+
# Create empty file and set its time
|
|
83
192
|
await ctx.fs.write_file(path, "")
|
|
193
|
+
await ctx.fs.utimes(path, date_time, date_time)
|
|
84
194
|
except FileNotFoundError:
|
|
85
195
|
stderr += f"touch: cannot touch '{f}': No such file or directory\n"
|
|
86
196
|
exit_code = 1
|
|
87
197
|
except IsADirectoryError:
|
|
88
|
-
# Touching a directory is fine
|
|
198
|
+
# Touching a directory is fine
|
|
89
199
|
pass
|
|
90
200
|
|
|
91
201
|
return ExecResult(stdout="", stderr=stderr, exit_code=exit_code)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Whoami command implementation.
|
|
2
|
+
|
|
3
|
+
Usage: whoami
|
|
4
|
+
|
|
5
|
+
Print the effective username.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ...types import CommandContext, ExecResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WhoamiCommand:
|
|
12
|
+
"""The whoami command."""
|
|
13
|
+
|
|
14
|
+
name = "whoami"
|
|
15
|
+
|
|
16
|
+
async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
|
|
17
|
+
"""Execute the whoami command."""
|
|
18
|
+
return ExecResult(stdout="user\n", stderr="", exit_code=0)
|
just_bash/fs/in_memory_fs.py
CHANGED
|
@@ -609,6 +609,28 @@ class InMemoryFs:
|
|
|
609
609
|
|
|
610
610
|
return entry.target
|
|
611
611
|
|
|
612
|
+
async def utimes(self, path: str, atime: float, mtime: float) -> None:
|
|
613
|
+
"""Set access and modification times for a file."""
|
|
614
|
+
resolved = self._resolve_path_with_symlinks(path)
|
|
615
|
+
entry = self._data.get(resolved)
|
|
616
|
+
if entry is None:
|
|
617
|
+
raise FileNotFoundError(f"No such file or directory: {path}")
|
|
618
|
+
# Update mtime (we only track mtime, atime is ignored)
|
|
619
|
+
if entry.type == "file":
|
|
620
|
+
self._data[resolved] = FileEntry(
|
|
621
|
+
content=entry.content, mode=entry.mode, mtime=mtime
|
|
622
|
+
)
|
|
623
|
+
elif entry.type == "directory":
|
|
624
|
+
self._data[resolved] = DirectoryEntry(mode=entry.mode, mtime=mtime)
|
|
625
|
+
elif entry.type == "symlink":
|
|
626
|
+
self._data[resolved] = SymlinkEntry(
|
|
627
|
+
target=entry.target, mode=entry.mode, mtime=mtime
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
async def realpath(self, path: str) -> str:
|
|
631
|
+
"""Resolve path to absolute canonical path (resolve all symlinks)."""
|
|
632
|
+
return self._resolve_path_with_symlinks(path)
|
|
633
|
+
|
|
612
634
|
def resolve_path(self, base: str, path: str) -> str:
|
|
613
635
|
"""Resolve a path relative to a base."""
|
|
614
636
|
if path.startswith("/"):
|
just_bash/fs/overlay_fs.py
CHANGED
|
@@ -155,6 +155,9 @@ class OverlayFs:
|
|
|
155
155
|
|
|
156
156
|
def _is_under_mount(self, path: str) -> bool:
|
|
157
157
|
"""Check if a normalized path is under the mount point."""
|
|
158
|
+
# Special case: root mount point means all paths are under it
|
|
159
|
+
if self._mount_point == "/":
|
|
160
|
+
return True
|
|
158
161
|
return path == self._mount_point or path.startswith(self._mount_point + "/")
|
|
159
162
|
|
|
160
163
|
def _to_real_path(self, virtual_path: str) -> Path | None:
|
|
@@ -172,7 +175,11 @@ class OverlayFs:
|
|
|
172
175
|
return self._root
|
|
173
176
|
|
|
174
177
|
# Strip mount point prefix
|
|
175
|
-
|
|
178
|
+
# Special case: when mount_point is "/", just strip the leading "/"
|
|
179
|
+
if self._mount_point == "/":
|
|
180
|
+
relative = normalized[1:] # Just strip the leading /
|
|
181
|
+
else:
|
|
182
|
+
relative = normalized[len(self._mount_point) + 1:] # +1 for the /
|
|
176
183
|
return self._root / relative
|
|
177
184
|
|
|
178
185
|
def _is_deleted(self, path: str) -> bool:
|
|
@@ -853,6 +860,20 @@ class OverlayFs:
|
|
|
853
860
|
)
|
|
854
861
|
|
|
855
862
|
target = real_path.readlink()
|
|
863
|
+
# Convert absolute real paths back to virtual paths to prevent leaking
|
|
864
|
+
if target.is_absolute():
|
|
865
|
+
# Resolve the target to handle symlinks in the path
|
|
866
|
+
# (e.g., macOS /var -> /private/var)
|
|
867
|
+
import os
|
|
868
|
+
resolved_target = Path(os.path.realpath(str(target)))
|
|
869
|
+
try:
|
|
870
|
+
relative = resolved_target.relative_to(self._root)
|
|
871
|
+
if self._mount_point == "/":
|
|
872
|
+
return f"/{relative}"
|
|
873
|
+
return f"{self._mount_point}/{relative}"
|
|
874
|
+
except ValueError:
|
|
875
|
+
# Target is outside the overlay root
|
|
876
|
+
pass
|
|
856
877
|
return str(target)
|
|
857
878
|
|
|
858
879
|
def resolve_path(self, base: str, path: str) -> str:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Interpreter module for just-bash."""
|
|
2
2
|
|
|
3
3
|
from .interpreter import Interpreter
|
|
4
|
-
from .types import InterpreterContext, InterpreterState, ShellOptions
|
|
4
|
+
from .types import InterpreterContext, InterpreterState, ShellOptions, VariableStore
|
|
5
5
|
from .errors import (
|
|
6
6
|
InterpreterError,
|
|
7
7
|
ExitError,
|
|
@@ -20,6 +20,7 @@ from .let import handle_let
|
|
|
20
20
|
from .readonly import handle_readonly
|
|
21
21
|
from .shopt import handle_shopt
|
|
22
22
|
from .alias import handle_alias, handle_unalias
|
|
23
|
+
from .getopts import handle_getopts
|
|
23
24
|
from .misc import (
|
|
24
25
|
handle_colon,
|
|
25
26
|
handle_true,
|
|
@@ -62,6 +63,7 @@ BUILTINS: dict[str, Callable[["InterpreterContext", list[str]], Awaitable["ExecR
|
|
|
62
63
|
"shopt": handle_shopt,
|
|
63
64
|
"alias": handle_alias,
|
|
64
65
|
"unalias": handle_unalias,
|
|
66
|
+
"getopts": handle_getopts,
|
|
65
67
|
":": handle_colon,
|
|
66
68
|
"true": handle_true,
|
|
67
69
|
"false": handle_false,
|
|
@@ -27,11 +27,9 @@ async def handle_break(ctx: "InterpreterContext", args: list[str]) -> "ExecResul
|
|
|
27
27
|
if levels < 1:
|
|
28
28
|
levels = 1
|
|
29
29
|
except ValueError:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
stdout="",
|
|
30
|
+
raise ExitError(
|
|
31
|
+
128,
|
|
33
32
|
stderr=f"bash: break: {args[0]}: numeric argument required\n",
|
|
34
|
-
exit_code=1,
|
|
35
33
|
)
|
|
36
34
|
|
|
37
35
|
# Check if we're in a loop
|
|
@@ -61,11 +59,9 @@ async def handle_continue(ctx: "InterpreterContext", args: list[str]) -> "ExecRe
|
|
|
61
59
|
if levels < 1:
|
|
62
60
|
levels = 1
|
|
63
61
|
except ValueError:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
stdout="",
|
|
62
|
+
raise ExitError(
|
|
63
|
+
128,
|
|
67
64
|
stderr=f"bash: continue: {args[0]}: numeric argument required\n",
|
|
68
|
-
exit_code=1,
|
|
69
65
|
)
|
|
70
66
|
|
|
71
67
|
# Check if we're in a loop
|