py-posix-shell 0.1.0__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.
@@ -0,0 +1,4 @@
1
+ """A small cross-platform POSIX-style shell."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -0,0 +1,418 @@
1
+ """Builtin utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import Callable, TextIO
8
+
9
+ from .errors import ShellExit
10
+ from .lexer import is_name
11
+
12
+ Builtin = Callable[[object, list[str], TextIO, TextIO, TextIO], int]
13
+
14
+
15
+ SPECIAL_BUILTINS = {
16
+ ":",
17
+ ".",
18
+ "break",
19
+ "continue",
20
+ "eval",
21
+ "exec",
22
+ "exit",
23
+ "export",
24
+ "readonly",
25
+ "return",
26
+ "set",
27
+ "shift",
28
+ "times",
29
+ "trap",
30
+ "unset",
31
+ }
32
+
33
+
34
+ def builtin_colon(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
35
+ return 0
36
+
37
+
38
+ def builtin_true(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
39
+ return 0
40
+
41
+
42
+ def builtin_false(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
43
+ return 1
44
+
45
+
46
+ def builtin_echo(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
47
+ args = argv[1:]
48
+ newline = True
49
+ if args and args[0] == "-n":
50
+ newline = False
51
+ args = args[1:]
52
+ stdout.write(" ".join(args))
53
+ if newline:
54
+ stdout.write("\n")
55
+ return 0
56
+
57
+
58
+ def builtin_pwd(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
59
+ stdout.write(os.getcwd() + "\n")
60
+ return 0
61
+
62
+
63
+ def builtin_cd(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
64
+ if len(argv) > 2:
65
+ stderr.write("cd: too many arguments\n")
66
+ return 2
67
+ target = argv[1] if len(argv) == 2 else shell.get_parameter("HOME")
68
+ if not target:
69
+ stderr.write("cd: HOME not set\n")
70
+ return 1
71
+ print_new_path = False
72
+ if target == "-":
73
+ target = shell.get_parameter("OLDPWD")
74
+ if not target:
75
+ stderr.write("cd: OLDPWD not set\n")
76
+ return 1
77
+ print_new_path = True
78
+ oldpwd = os.getcwd()
79
+ try:
80
+ os.chdir(os.path.expanduser(target))
81
+ except OSError as exc:
82
+ stderr.write(f"cd: {target}: {exc.strerror or exc}\n")
83
+ return 1
84
+ newpwd = os.getcwd()
85
+ shell.set_parameter("OLDPWD", oldpwd, export=True)
86
+ shell.set_parameter("PWD", newpwd, export=True)
87
+ if print_new_path:
88
+ stdout.write(newpwd + "\n")
89
+ return 0
90
+
91
+
92
+ def builtin_exit(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
93
+ if len(argv) > 2:
94
+ stderr.write("exit: too many arguments\n")
95
+ return 2
96
+ if len(argv) == 1:
97
+ raise ShellExit(shell.last_status)
98
+ try:
99
+ status = int(argv[1], 10) & 0xFF
100
+ except ValueError:
101
+ stderr.write(f"exit: {argv[1]}: numeric argument required\n")
102
+ raise ShellExit(2)
103
+ raise ShellExit(status)
104
+
105
+
106
+ def builtin_export(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
107
+ if len(argv) == 1 or argv[1:] == ["-p"]:
108
+ for name in sorted(shell.env):
109
+ stdout.write(f"export {name}={quote_for_display(shell.env[name])}\n")
110
+ return 0
111
+
112
+ status = 0
113
+ for arg in argv[1:]:
114
+ if arg == "-p":
115
+ continue
116
+ if "=" in arg:
117
+ name, value = arg.split("=", 1)
118
+ else:
119
+ name = arg
120
+ value = shell.get_parameter(name)
121
+ if not is_name(name):
122
+ stderr.write(f"export: {arg}: not a valid identifier\n")
123
+ status = 1
124
+ continue
125
+ shell.set_parameter(name, value, export=True)
126
+ return status
127
+
128
+
129
+ def builtin_unset(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
130
+ status = 0
131
+ for name in argv[1:]:
132
+ if not is_name(name):
133
+ stderr.write(f"unset: {name}: not a valid identifier\n")
134
+ status = 1
135
+ continue
136
+ shell.unset_parameter(name)
137
+ return status
138
+
139
+
140
+ def builtin_set(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
141
+ if len(argv) == 1:
142
+ merged = dict(shell.env)
143
+ merged.update(shell.vars)
144
+ for name in sorted(merged):
145
+ stdout.write(f"{name}={quote_for_display(merged[name])}\n")
146
+ return 0
147
+ if argv[1] == "--":
148
+ shell.positional = argv[2:]
149
+ return 0
150
+ stderr.write("set: only 'set' and 'set -- args...' are implemented\n")
151
+ return 2
152
+
153
+
154
+ def builtin_shift(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
155
+ if len(argv) > 2:
156
+ stderr.write("shift: too many arguments\n")
157
+ return 2
158
+ try:
159
+ count = int(argv[1], 10) if len(argv) == 2 else 1
160
+ except ValueError:
161
+ stderr.write(f"shift: {argv[1]}: numeric argument required\n")
162
+ return 2
163
+ if count < 0 or count > len(shell.positional):
164
+ stderr.write("shift: shift count out of range\n")
165
+ return 1
166
+ del shell.positional[:count]
167
+ return 0
168
+
169
+
170
+ def builtin_printf(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
171
+ if len(argv) == 1:
172
+ return 0
173
+ fmt = decode_escapes(argv[1])
174
+ args = argv[2:] or [""]
175
+ index = 0
176
+ while index < len(args):
177
+ wrote_conversion = False
178
+ i = 0
179
+ while i < len(fmt):
180
+ char = fmt[i]
181
+ if char != "%":
182
+ stdout.write(char)
183
+ i += 1
184
+ continue
185
+ if i + 1 < len(fmt) and fmt[i + 1] == "%":
186
+ stdout.write("%")
187
+ i += 2
188
+ continue
189
+ spec_start = i
190
+ i += 1
191
+ while i < len(fmt) and fmt[i] in "#0- +0123456789.":
192
+ i += 1
193
+ if i >= len(fmt):
194
+ stdout.write(fmt[spec_start:])
195
+ break
196
+ spec = fmt[i]
197
+ i += 1
198
+ arg = args[index] if index < len(args) else ""
199
+ index += 1
200
+ wrote_conversion = True
201
+ if spec == "s":
202
+ stdout.write(arg)
203
+ elif spec == "b":
204
+ stdout.write(decode_escapes(arg))
205
+ elif spec in "diu":
206
+ try:
207
+ stdout.write(str(int(arg, 0)))
208
+ except ValueError:
209
+ stdout.write("0")
210
+ elif spec in "xX":
211
+ try:
212
+ value = int(arg, 0)
213
+ except ValueError:
214
+ value = 0
215
+ stdout.write(format(value, spec))
216
+ else:
217
+ stdout.write("%" + spec)
218
+ if not wrote_conversion:
219
+ break
220
+ return 0
221
+
222
+
223
+ def builtin_read(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
224
+ args = argv[1:]
225
+ raw = False
226
+ if args and args[0] == "-r":
227
+ raw = True
228
+ args = args[1:]
229
+ if not args:
230
+ args = ["REPLY"]
231
+ line = stdin.readline()
232
+ if line == "":
233
+ return 1
234
+ line = line.rstrip("\n")
235
+ if not raw:
236
+ line = line.replace("\\\n", "")
237
+ values = line.split()
238
+ for index, name in enumerate(args):
239
+ if not is_name(name):
240
+ stderr.write(f"read: {name}: not a valid identifier\n")
241
+ return 2
242
+ if index == len(args) - 1:
243
+ value = " ".join(values[index:])
244
+ else:
245
+ value = values[index] if index < len(values) else ""
246
+ shell.set_parameter(name, value)
247
+ return 0
248
+
249
+
250
+ def builtin_type(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
251
+ if len(argv) == 1:
252
+ stderr.write("type: missing operand\n")
253
+ return 2
254
+ status = 0
255
+ for name in argv[1:]:
256
+ if shell.is_builtin(name):
257
+ stdout.write(f"{name} is a shell builtin\n")
258
+ continue
259
+ path = shell.which(name)
260
+ if path:
261
+ stdout.write(f"{name} is {path}\n")
262
+ else:
263
+ stderr.write(f"type: {name}: not found\n")
264
+ status = 1
265
+ return status
266
+
267
+
268
+ def builtin_command(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
269
+ args = argv[1:]
270
+ if not args:
271
+ return 0
272
+ if args[0] in {"-v", "-V"}:
273
+ status = 0
274
+ for name in args[1:]:
275
+ if shell.is_builtin(name):
276
+ stdout.write(f"{name}\n" if args[0] == "-v" else f"{name} is a shell builtin\n")
277
+ continue
278
+ path = shell.which(name)
279
+ if path:
280
+ stdout.write(path + "\n")
281
+ else:
282
+ status = 1
283
+ return status
284
+ return shell.run_preexpanded(args, stdin=stdin, stdout=stdout, stderr=stderr)
285
+
286
+
287
+ def builtin_env(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
288
+ args = argv[1:]
289
+ env = {} if args and args[0] == "-i" else dict(shell.env)
290
+ if args and args[0] == "-i":
291
+ args = args[1:]
292
+ while args and "=" in args[0]:
293
+ name, value = args[0].split("=", 1)
294
+ env[name] = value
295
+ args = args[1:]
296
+ if not args:
297
+ for name in sorted(env):
298
+ stdout.write(f"{name}={env[name]}\n")
299
+ return 0
300
+ return shell.run_external(args, env=env, stdin=stdin, stdout=stdout, stderr=stderr)
301
+
302
+
303
+ def builtin_test(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
304
+ args = argv[1:]
305
+ if argv[0] == "[":
306
+ if not args or args[-1] != "]":
307
+ stderr.write("[: missing closing ]\n")
308
+ return 2
309
+ args = args[:-1]
310
+ return 0 if eval_test(args) else 1
311
+
312
+
313
+ def eval_test(args: list[str]) -> bool:
314
+ if not args:
315
+ return False
316
+ if len(args) == 1:
317
+ return args[0] != ""
318
+ if len(args) == 2:
319
+ op, value = args
320
+ if op == "!":
321
+ return not eval_test([value])
322
+ if op == "-n":
323
+ return value != ""
324
+ if op == "-z":
325
+ return value == ""
326
+ if op == "-e":
327
+ return os.path.exists(value)
328
+ if op == "-f":
329
+ return os.path.isfile(value)
330
+ if op == "-d":
331
+ return os.path.isdir(value)
332
+ if len(args) == 3:
333
+ left, op, right = args
334
+ if op == "=":
335
+ return left == right
336
+ if op == "!=":
337
+ return left != right
338
+ if op in {"-eq", "-ne", "-gt", "-ge", "-lt", "-le"}:
339
+ try:
340
+ a = int(left, 10)
341
+ b = int(right, 10)
342
+ except ValueError:
343
+ return False
344
+ return {
345
+ "-eq": a == b,
346
+ "-ne": a != b,
347
+ "-gt": a > b,
348
+ "-ge": a >= b,
349
+ "-lt": a < b,
350
+ "-le": a <= b,
351
+ }[op]
352
+ return False
353
+
354
+
355
+ def quote_for_display(value: str) -> str:
356
+ return "'" + value.replace("'", "'\\''") + "'"
357
+
358
+
359
+ def decode_escapes(value: str) -> str:
360
+ result: list[str] = []
361
+ i = 0
362
+ while i < len(value):
363
+ char = value[i]
364
+ if char != "\\":
365
+ result.append(char)
366
+ i += 1
367
+ continue
368
+ if i + 1 >= len(value):
369
+ result.append("\\")
370
+ break
371
+ nxt = value[i + 1]
372
+ mapping = {
373
+ "a": "\a",
374
+ "b": "\b",
375
+ "f": "\f",
376
+ "n": "\n",
377
+ "r": "\r",
378
+ "t": "\t",
379
+ "v": "\v",
380
+ "\\": "\\",
381
+ }
382
+ if nxt in mapping:
383
+ result.append(mapping[nxt])
384
+ i += 2
385
+ continue
386
+ if nxt in "01234567":
387
+ j = i + 1
388
+ while j < len(value) and j < i + 4 and value[j] in "01234567":
389
+ j += 1
390
+ result.append(chr(int(value[i + 1 : j], 8)))
391
+ i = j
392
+ continue
393
+ result.append(nxt)
394
+ i += 2
395
+ return "".join(result)
396
+
397
+
398
+ BUILTINS: dict[str, Builtin] = {
399
+ ":": builtin_colon,
400
+ "true": builtin_true,
401
+ "false": builtin_false,
402
+ "echo": builtin_echo,
403
+ "pwd": builtin_pwd,
404
+ "cd": builtin_cd,
405
+ "exit": builtin_exit,
406
+ "export": builtin_export,
407
+ "unset": builtin_unset,
408
+ "set": builtin_set,
409
+ "shift": builtin_shift,
410
+ "printf": builtin_printf,
411
+ "read": builtin_read,
412
+ "type": builtin_type,
413
+ "command": builtin_command,
414
+ "env": builtin_env,
415
+ "test": builtin_test,
416
+ "[": builtin_test,
417
+ }
418
+
py_posix_shell/cli.py ADDED
@@ -0,0 +1,60 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .errors import ShellExit
10
+ from .shell import Shell
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="pysh",
16
+ description="Run a small cross-platform POSIX-style shell.",
17
+ )
18
+ parser.add_argument("-c", dest="command", help="read commands from COMMAND")
19
+ parser.add_argument("-i", dest="interactive", action="store_true", help="force interactive mode")
20
+ parser.add_argument("--version", action="store_true", help="print version and exit")
21
+ parser.add_argument("script", nargs="?", help="shell script to execute")
22
+ parser.add_argument("args", nargs=argparse.REMAINDER, help="arguments for the script")
23
+ return parser
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ args = build_parser().parse_args(argv)
28
+ if args.version:
29
+ print(f"py-posix-shell {__version__}")
30
+ return 0
31
+
32
+ if args.command is not None:
33
+ shell = Shell(argv0="pysh", positional=args.args)
34
+ try:
35
+ return shell.execute(args.command)
36
+ except ShellExit as exc:
37
+ return exc.status
38
+
39
+ if args.script:
40
+ try:
41
+ with open(args.script, "r", encoding="utf-8") as file:
42
+ source = file.read()
43
+ except OSError as exc:
44
+ print(f"pysh: {args.script}: {exc}", file=sys.stderr)
45
+ return 1
46
+ shell = Shell(argv0=args.script, positional=args.args)
47
+ try:
48
+ return shell.execute(source)
49
+ except ShellExit as exc:
50
+ return exc.status
51
+
52
+ interactive = args.interactive or sys.stdin.isatty()
53
+ shell = Shell(argv0="pysh", interactive=interactive)
54
+ if interactive:
55
+ return shell.repl()
56
+ try:
57
+ return shell.execute(sys.stdin.read())
58
+ except ShellExit as exc:
59
+ return exc.status
60
+
@@ -0,0 +1,26 @@
1
+ """Shared exception types for py-posix-shell."""
2
+
3
+
4
+ class ShellError(Exception):
5
+ """Base class for shell errors."""
6
+
7
+
8
+ class LexerError(ShellError):
9
+ """Raised when shell source cannot be tokenized."""
10
+
11
+
12
+ class ParseError(ShellError):
13
+ """Raised when shell tokens do not form a valid command list."""
14
+
15
+
16
+ class ExpansionError(ShellError):
17
+ """Raised when shell expansion fails."""
18
+
19
+
20
+ class ShellExit(BaseException):
21
+ """Internal control-flow exception for the exit builtin."""
22
+
23
+ def __init__(self, status: int = 0) -> None:
24
+ self.status = status
25
+ super().__init__(status)
26
+