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.
Files changed (49) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +362 -17
  4. just_bash/commands/cat/cat.py +5 -1
  5. just_bash/commands/echo/echo.py +33 -1
  6. just_bash/commands/grep/grep.py +141 -3
  7. just_bash/commands/od/od.py +144 -30
  8. just_bash/commands/printf/printf.py +289 -87
  9. just_bash/commands/pwd/pwd.py +32 -2
  10. just_bash/commands/read/read.py +243 -64
  11. just_bash/commands/readlink/readlink.py +3 -9
  12. just_bash/commands/registry.py +32 -0
  13. just_bash/commands/rmdir/__init__.py +5 -0
  14. just_bash/commands/rmdir/rmdir.py +160 -0
  15. just_bash/commands/sed/sed.py +142 -31
  16. just_bash/commands/shuf/__init__.py +5 -0
  17. just_bash/commands/shuf/shuf.py +242 -0
  18. just_bash/commands/stat/stat.py +9 -0
  19. just_bash/commands/time/__init__.py +5 -0
  20. just_bash/commands/time/time.py +74 -0
  21. just_bash/commands/touch/touch.py +118 -8
  22. just_bash/commands/whoami/__init__.py +5 -0
  23. just_bash/commands/whoami/whoami.py +18 -0
  24. just_bash/fs/in_memory_fs.py +22 -0
  25. just_bash/fs/overlay_fs.py +22 -1
  26. just_bash/interpreter/__init__.py +1 -1
  27. just_bash/interpreter/builtins/__init__.py +2 -0
  28. just_bash/interpreter/builtins/control.py +4 -8
  29. just_bash/interpreter/builtins/declare.py +321 -24
  30. just_bash/interpreter/builtins/getopts.py +163 -0
  31. just_bash/interpreter/builtins/let.py +2 -2
  32. just_bash/interpreter/builtins/local.py +71 -5
  33. just_bash/interpreter/builtins/misc.py +22 -6
  34. just_bash/interpreter/builtins/readonly.py +38 -10
  35. just_bash/interpreter/builtins/set.py +58 -8
  36. just_bash/interpreter/builtins/test.py +136 -19
  37. just_bash/interpreter/builtins/unset.py +62 -10
  38. just_bash/interpreter/conditionals.py +29 -4
  39. just_bash/interpreter/control_flow.py +61 -17
  40. just_bash/interpreter/expansion.py +1647 -104
  41. just_bash/interpreter/interpreter.py +436 -69
  42. just_bash/interpreter/types.py +263 -2
  43. just_bash/parser/__init__.py +2 -0
  44. just_bash/parser/lexer.py +295 -26
  45. just_bash/parser/parser.py +523 -64
  46. just_bash/types.py +11 -0
  47. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  48. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/RECORD +49 -40
  49. {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
- for c in arg[1:]:
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
- # Touching a directory - we can't easily update dir mtime
73
- # in current implementation, so just continue
182
+ # Update directory timestamp if possible
183
+ await ctx.fs.utimes(path, date_time, date_time)
74
184
  continue
75
- # File exists - read and re-write to update timestamp
76
- content = await ctx.fs.read_file(path)
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, just update timestamp (no-op)
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,5 @@
1
+ """Whoami command."""
2
+
3
+ from .whoami import WhoamiCommand
4
+
5
+ __all__ = ["WhoamiCommand"]
@@ -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)
@@ -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("/"):
@@ -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
- relative = normalized[len(self._mount_point) + 1:] # +1 for the /
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
- from ...types import ExecResult
31
- return ExecResult(
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
- from ...types import ExecResult
65
- return ExecResult(
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