my-pyshell 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.
- my_pyshell-0.1.0.dist-info/METADATA +7 -0
- my_pyshell-0.1.0.dist-info/RECORD +17 -0
- my_pyshell-0.1.0.dist-info/WHEEL +5 -0
- my_pyshell-0.1.0.dist-info/entry_points.txt +2 -0
- my_pyshell-0.1.0.dist-info/licenses/LICENSE +21 -0
- my_pyshell-0.1.0.dist-info/top_level.txt +1 -0
- pyshell/__init__.py +0 -0
- pyshell/__main__.py +5 -0
- pyshell/banner.py +87 -0
- pyshell/builtins.py +173 -0
- pyshell/completion.py +67 -0
- pyshell/executor.py +181 -0
- pyshell/history.py +44 -0
- pyshell/jobs.py +78 -0
- pyshell/main.py +17 -0
- pyshell/parser.py +256 -0
- pyshell/shell.py +95 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
my_pyshell-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
|
|
2
|
+
pyshell/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
pyshell/__main__.py,sha256=CA1H9WLtwsdEFT4nO86ePUdxjPAmx6QH-E9fQyizEU8,83
|
|
4
|
+
pyshell/banner.py,sha256=u2imeNo8ecjAUVXIRC201GRzKtBG-1W_BgdHiN3gXoQ,2427
|
|
5
|
+
pyshell/builtins.py,sha256=QK87fUfRq-im7C_HqIlRGrycLgL3vgK2u683C_-6I_k,4476
|
|
6
|
+
pyshell/completion.py,sha256=3CiTnvxQ0XSYVOStxU5c_dd47dMR4d_GGce8qBWgOes,2120
|
|
7
|
+
pyshell/executor.py,sha256=-ztmvK7oKxMCutYJts9pnmuohDzVrBWpUAaiGZXYToE,6784
|
|
8
|
+
pyshell/history.py,sha256=JjQURTWp4dNIasophMST-asVKkxJGCAqAftA14FV-p4,1646
|
|
9
|
+
pyshell/jobs.py,sha256=M5DTo5FN48RUuFUNyY_HI5dLNaWEF_Gf-m545n--xZ0,2393
|
|
10
|
+
pyshell/main.py,sha256=FrxhNUaMZ_wB9XQgxL_mNQk0g_3ydAS0OvDdutcloI4,329
|
|
11
|
+
pyshell/parser.py,sha256=sNgUpLEsnXjTpV1JTm_ovwER0zREqE514wSqF7d3Ago,7630
|
|
12
|
+
pyshell/shell.py,sha256=E6KmRoQ-QIBHQC8uZbYKCYPNJ-a1I3IaBfOos5YMMnc,3075
|
|
13
|
+
my_pyshell-0.1.0.dist-info/METADATA,sha256=zgLavDIv7IxlJp-4dxHQruANY_oN5ewptY-ZRsH5CLs,181
|
|
14
|
+
my_pyshell-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
my_pyshell-0.1.0.dist-info/entry_points.txt,sha256=3SaEQUstZJocAiUjvV4aXZOBLG26qfr2y3Fidtne4t0,46
|
|
16
|
+
my_pyshell-0.1.0.dist-info/top_level.txt,sha256=mqLMJYn_chB2OkoBIa-6xciUBr3gcua8ncfr3vq8W94,8
|
|
17
|
+
my_pyshell-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyshell
|
pyshell/__init__.py
ADDED
|
File without changes
|
pyshell/__main__.py
ADDED
pyshell/banner.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Startup banner: an animated ASCII-art splash shown when pyshell launches.
|
|
2
|
+
|
|
3
|
+
Skips itself automatically when stdout isn't a real terminal (piped
|
|
4
|
+
input, CI, tests) so it never interferes with scripting or pytest.
|
|
5
|
+
Can also be disabled explicitly with PYSHELL_NO_BANNER=1.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import getpass
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
RESET = "\033[0m"
|
|
17
|
+
BOLD = "\033[1m"
|
|
18
|
+
DIM = "\033[2m"
|
|
19
|
+
|
|
20
|
+
# A gradient of 256-color codes, cyan -> blue -> magenta, one per art line.
|
|
21
|
+
_GRADIENT = [51, 45, 39, 33, 27, 63, 99, 135]
|
|
22
|
+
|
|
23
|
+
_ART = [
|
|
24
|
+
r" ____ _____ __ ____",
|
|
25
|
+
r" / __ \__ __/ ___// /_ ___ / / /",
|
|
26
|
+
r" / /_/ / / / /\__ \/ __ \/ _ \/ / / ",
|
|
27
|
+
r" / ____/ /_/ /___/ / / / / __/ / / ",
|
|
28
|
+
r"/_/ \__, //____/_/ /_/\___/_/_/ ",
|
|
29
|
+
r" /____/ ",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _color(code: int) -> str:
|
|
34
|
+
return f"\033[38;5;{code}m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _supports_color() -> bool:
|
|
38
|
+
if os.environ.get("NO_COLOR"):
|
|
39
|
+
return False
|
|
40
|
+
return sys.stdout.isatty()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _type_out(text: str, delay: float = 0.012) -> None:
|
|
44
|
+
for ch in text:
|
|
45
|
+
sys.stdout.write(ch)
|
|
46
|
+
sys.stdout.flush()
|
|
47
|
+
time.sleep(delay)
|
|
48
|
+
sys.stdout.write("\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def show(version: str = "0.1.0") -> None:
|
|
52
|
+
"""Print the animated banner. No-op when not attached to a real tty."""
|
|
53
|
+
if not sys.stdout.isatty() or os.environ.get("PYSHELL_NO_BANNER"):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
color = _supports_color()
|
|
57
|
+
width = shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
58
|
+
|
|
59
|
+
# Draw the ASCII-art logo one line at a time, gradient-colored,
|
|
60
|
+
# each line sliding in with a tiny delay for a "build up" feel.
|
|
61
|
+
for i, line in enumerate(_ART):
|
|
62
|
+
prefix = _color(_GRADIENT[i % len(_GRADIENT)]) if color else ""
|
|
63
|
+
suffix = RESET if color else ""
|
|
64
|
+
sys.stdout.write(prefix + line + suffix + "\n")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
time.sleep(0.05)
|
|
67
|
+
|
|
68
|
+
print()
|
|
69
|
+
|
|
70
|
+
user = getpass.getuser()
|
|
71
|
+
py_ver = platform.python_version()
|
|
72
|
+
tagline = f"welcome back, {user} — pyshell v{version} (python {py_ver})"
|
|
73
|
+
if color:
|
|
74
|
+
sys.stdout.write(DIM)
|
|
75
|
+
_type_out(tagline, delay=0.01)
|
|
76
|
+
if color:
|
|
77
|
+
sys.stdout.write(RESET)
|
|
78
|
+
|
|
79
|
+
hint = "type 'help' to see builtins, or 'exit' to leave"
|
|
80
|
+
if color:
|
|
81
|
+
sys.stdout.write(DIM)
|
|
82
|
+
print(hint)
|
|
83
|
+
if color:
|
|
84
|
+
sys.stdout.write(RESET)
|
|
85
|
+
|
|
86
|
+
print("-" * min(width, 60))
|
|
87
|
+
sys.stdout.flush()
|
pyshell/builtins.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Builtin shell commands.
|
|
2
|
+
|
|
3
|
+
Each builtin has the signature: fn(args: list[str], shell: "Shell") -> int
|
|
4
|
+
and returns an exit status (0 for success), writing to shell.stdout/stderr
|
|
5
|
+
streams so redirection works uniformly for builtins and external commands.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bi_exit(args, shell) -> int:
|
|
14
|
+
code = 0
|
|
15
|
+
if args:
|
|
16
|
+
try:
|
|
17
|
+
code = int(args[0])
|
|
18
|
+
except ValueError:
|
|
19
|
+
code = 0
|
|
20
|
+
raise SystemExit(code)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bi_echo(args, shell) -> int:
|
|
24
|
+
print(" ".join(args), file=shell.stdout)
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bi_pwd(args, shell) -> int:
|
|
29
|
+
print(os.getcwd(), file=shell.stdout)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def bi_cd(args, shell) -> int:
|
|
34
|
+
target = args[0] if args else os.environ.get("HOME", os.path.expanduser("~"))
|
|
35
|
+
if target == "-":
|
|
36
|
+
target = os.environ.get("OLDPWD", os.getcwd())
|
|
37
|
+
print(target, file=shell.stdout)
|
|
38
|
+
target = os.path.expanduser(target)
|
|
39
|
+
old = os.getcwd()
|
|
40
|
+
try:
|
|
41
|
+
os.chdir(target)
|
|
42
|
+
os.environ["OLDPWD"] = old
|
|
43
|
+
os.environ["PWD"] = os.getcwd()
|
|
44
|
+
return 0
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
print(f"cd: {args[0] if args else target}: No such file or directory", file=shell.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
except NotADirectoryError:
|
|
49
|
+
print(f"cd: {args[0] if args else target}: Not a directory", file=shell.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
except PermissionError:
|
|
52
|
+
print(f"cd: {args[0] if args else target}: Permission denied", file=shell.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bi_type(args, shell) -> int:
|
|
57
|
+
if not args:
|
|
58
|
+
return 0
|
|
59
|
+
status = 0
|
|
60
|
+
for cmd in args:
|
|
61
|
+
if cmd in BUILTINS:
|
|
62
|
+
print(f"{cmd} is a shell builtin", file=shell.stdout)
|
|
63
|
+
else:
|
|
64
|
+
path = shell.find_executable(cmd)
|
|
65
|
+
if path:
|
|
66
|
+
print(f"{cmd} is {path}", file=shell.stdout)
|
|
67
|
+
else:
|
|
68
|
+
print(f"{cmd}: not found", file=shell.stderr)
|
|
69
|
+
status = 1
|
|
70
|
+
return status
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bi_export(args, shell) -> int:
|
|
74
|
+
if not args:
|
|
75
|
+
for k, v in sorted(os.environ.items()):
|
|
76
|
+
print(f"export {k}={v}", file=shell.stdout)
|
|
77
|
+
return 0
|
|
78
|
+
for arg in args:
|
|
79
|
+
if "=" in arg:
|
|
80
|
+
key, _, value = arg.partition("=")
|
|
81
|
+
os.environ[key] = value
|
|
82
|
+
else:
|
|
83
|
+
os.environ.setdefault(arg, "")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bi_unset(args, shell) -> int:
|
|
88
|
+
for arg in args:
|
|
89
|
+
os.environ.pop(arg, None)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def bi_env(args, shell) -> int:
|
|
94
|
+
for k, v in sorted(os.environ.items()):
|
|
95
|
+
print(f"{k}={v}", file=shell.stdout)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def bi_help(args, shell) -> int:
|
|
100
|
+
print("pyshell builtins:", file=shell.stdout)
|
|
101
|
+
for name in sorted(BUILTINS):
|
|
102
|
+
print(f" {name}", file=shell.stdout)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def bi_history(args, shell) -> int:
|
|
107
|
+
count = None
|
|
108
|
+
if args:
|
|
109
|
+
try:
|
|
110
|
+
count = int(args[0])
|
|
111
|
+
except ValueError:
|
|
112
|
+
print(f"history: {args[0]}: numeric argument required", file=shell.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
for idx, entry in shell.history.show(count):
|
|
115
|
+
print(f"{idx:>5} {entry}", file=shell.stdout)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def bi_jobs(args, shell) -> int:
|
|
120
|
+
shell.jobs.reap_finished()
|
|
121
|
+
latest = shell.jobs.latest()
|
|
122
|
+
latest_id = latest.job_id if latest else None
|
|
123
|
+
for job in shell.jobs.all_sorted():
|
|
124
|
+
print(shell.jobs.format_line(job, latest_id), file=shell.stdout)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_job(args, shell):
|
|
129
|
+
if not args:
|
|
130
|
+
return shell.jobs.latest()
|
|
131
|
+
spec = args[0].lstrip("%")
|
|
132
|
+
try:
|
|
133
|
+
job_id = int(spec)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
return shell.jobs.get(job_id)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def bi_fg(args, shell) -> int:
|
|
140
|
+
job = _resolve_job(args, shell)
|
|
141
|
+
if job is None:
|
|
142
|
+
print("fg: no such job", file=shell.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
print(job.command, file=shell.stdout)
|
|
145
|
+
shell.jobs.continue_job(job, foreground=True)
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def bi_bg(args, shell) -> int:
|
|
150
|
+
job = _resolve_job(args, shell)
|
|
151
|
+
if job is None:
|
|
152
|
+
print("bg: no such job", file=shell.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
shell.jobs.continue_job(job, foreground=False)
|
|
155
|
+
print(f"[{job.job_id}]+ {job.command} &", file=shell.stdout)
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
BUILTINS = {
|
|
160
|
+
"exit": bi_exit,
|
|
161
|
+
"echo": bi_echo,
|
|
162
|
+
"pwd": bi_pwd,
|
|
163
|
+
"cd": bi_cd,
|
|
164
|
+
"type": bi_type,
|
|
165
|
+
"export": bi_export,
|
|
166
|
+
"unset": bi_unset,
|
|
167
|
+
"env": bi_env,
|
|
168
|
+
"help": bi_help,
|
|
169
|
+
"history": bi_history,
|
|
170
|
+
"jobs": bi_jobs,
|
|
171
|
+
"fg": bi_fg,
|
|
172
|
+
"bg": bi_bg,
|
|
173
|
+
}
|
pyshell/completion.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tab completion, hooked into GNU readline.
|
|
2
|
+
|
|
3
|
+
Completes the first word of a line against builtins + everything on
|
|
4
|
+
PATH. Falls back to filename completion for later words (readline
|
|
5
|
+
does this itself once we return None enough times).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from .builtins import BUILTINS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _path_executables() -> set[str]:
|
|
15
|
+
names = set()
|
|
16
|
+
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
17
|
+
if not directory or not os.path.isdir(directory):
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
for entry in os.scandir(directory):
|
|
21
|
+
if entry.is_file() and os.access(entry.path, os.X_OK):
|
|
22
|
+
names.add(entry.name)
|
|
23
|
+
except PermissionError:
|
|
24
|
+
continue
|
|
25
|
+
return names
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Completer:
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self._matches: list[str] = []
|
|
31
|
+
|
|
32
|
+
def complete(self, text: str, state: int) -> str | None:
|
|
33
|
+
import readline
|
|
34
|
+
|
|
35
|
+
buf = readline.get_line_buffer()
|
|
36
|
+
is_first_word = buf[:readline.get_begidx()].strip() == ""
|
|
37
|
+
|
|
38
|
+
if state == 0:
|
|
39
|
+
if is_first_word:
|
|
40
|
+
candidates = set(BUILTINS) | _path_executables()
|
|
41
|
+
else:
|
|
42
|
+
# filename completion for later words
|
|
43
|
+
dirname, _, prefix = text.rpartition(os.sep)
|
|
44
|
+
search_dir = dirname or "."
|
|
45
|
+
try:
|
|
46
|
+
candidates = {
|
|
47
|
+
os.path.join(dirname, e) if dirname else e
|
|
48
|
+
for e in os.listdir(search_dir)
|
|
49
|
+
if e.startswith(prefix)
|
|
50
|
+
}
|
|
51
|
+
except OSError:
|
|
52
|
+
candidates = set()
|
|
53
|
+
self._matches = sorted(c for c in candidates if c.startswith(text))
|
|
54
|
+
try:
|
|
55
|
+
return self._matches[state]
|
|
56
|
+
except IndexError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install() -> None:
|
|
61
|
+
try:
|
|
62
|
+
import readline
|
|
63
|
+
except ImportError:
|
|
64
|
+
return # e.g. some minimal/windows builds lack readline
|
|
65
|
+
readline.set_completer(Completer().complete)
|
|
66
|
+
readline.parse_and_bind("tab: complete")
|
|
67
|
+
readline.set_completer_delims(" \t\n")
|
pyshell/executor.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Turns parsed Commands into running processes.
|
|
2
|
+
|
|
3
|
+
Design:
|
|
4
|
+
- A single, non-backgrounded builtin runs in-process (the shell's
|
|
5
|
+
own process). This is required for state-mutating builtins like
|
|
6
|
+
cd/export/unset/exit, and it's also what makes output correctly
|
|
7
|
+
visible whether the shell's stdout is a real fd or a Python
|
|
8
|
+
object (as in tests). Everything else - external programs, piped
|
|
9
|
+
builtins, backgrounded commands - runs in a forked child so pipe
|
|
10
|
+
plumbing and job control work uniformly.
|
|
11
|
+
- Each pipeline gets its own process group so `jobs`/`fg`/`bg` and
|
|
12
|
+
signals (Ctrl-C, Ctrl-Z equivalents) can target the whole pipeline.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .builtins import BUILTINS
|
|
20
|
+
from .parser import Command, Redirections
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _open_redirections(redirs: Redirections):
|
|
24
|
+
"""Open any files a Command's redirection targets, returning fds to dup2."""
|
|
25
|
+
stdin_fd = stdout_fd = stderr_fd = None
|
|
26
|
+
if redirs.stdin_path:
|
|
27
|
+
stdin_fd = os.open(redirs.stdin_path, os.O_RDONLY)
|
|
28
|
+
if redirs.stdout_path:
|
|
29
|
+
flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if redirs.stdout_append else os.O_TRUNC)
|
|
30
|
+
stdout_fd = os.open(redirs.stdout_path, flags, 0o644)
|
|
31
|
+
if redirs.stderr_path:
|
|
32
|
+
flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if redirs.stderr_append else os.O_TRUNC)
|
|
33
|
+
stderr_fd = os.open(redirs.stderr_path, flags, 0o644)
|
|
34
|
+
return stdin_fd, stdout_fd, stderr_fd
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_builtin_child(command: Command, shell) -> None:
|
|
38
|
+
"""Run a builtin inside a forked child, then _exit."""
|
|
39
|
+
fn = BUILTINS[command.args[0]]
|
|
40
|
+
try:
|
|
41
|
+
status = fn(command.args[1:], shell)
|
|
42
|
+
except SystemExit as e:
|
|
43
|
+
status = e.code or 0
|
|
44
|
+
except Exception as e: # noqa: BLE001 - last line of defense in a child process
|
|
45
|
+
print(f"{command.args[0]}: {e}", file=sys.stderr)
|
|
46
|
+
status = 1
|
|
47
|
+
sys.stdout.flush()
|
|
48
|
+
sys.stderr.flush()
|
|
49
|
+
os._exit(status if isinstance(status, int) else 0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _exec_external(command: Command, shell) -> None:
|
|
53
|
+
"""Replace the current (forked) process image with an external program."""
|
|
54
|
+
path = shell.find_executable(command.args[0])
|
|
55
|
+
if path is None:
|
|
56
|
+
print(f"{command.args[0]}: command not found", file=sys.stderr)
|
|
57
|
+
os._exit(127)
|
|
58
|
+
try:
|
|
59
|
+
os.execv(path, command.args)
|
|
60
|
+
except OSError as e:
|
|
61
|
+
print(f"{command.args[0]}: {e}", file=sys.stderr)
|
|
62
|
+
os._exit(126)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_pipeline(commands: list[Command], background: bool, raw_line: str, shell) -> int:
|
|
66
|
+
if not commands:
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
# Fast path: a single builtin with no pipe and not backgrounded runs
|
|
70
|
+
# inline, in the shell's own process. Redirection is applied by
|
|
71
|
+
# temporarily swapping shell.stdout/stderr to a file handle so it
|
|
72
|
+
# works whether the shell writes to real fds or a Python stream.
|
|
73
|
+
if len(commands) == 1 and not background and commands[0].args[0] in BUILTINS:
|
|
74
|
+
command = commands[0]
|
|
75
|
+
saved_stdout, saved_stderr = shell.stdout, shell.stderr
|
|
76
|
+
opened_files = []
|
|
77
|
+
try:
|
|
78
|
+
if command.redirs.stdin_path:
|
|
79
|
+
# Builtins in this shell don't read stdin from a file;
|
|
80
|
+
# open+close to validate the path and surface errors.
|
|
81
|
+
fd = os.open(command.redirs.stdin_path, os.O_RDONLY)
|
|
82
|
+
os.close(fd)
|
|
83
|
+
if command.redirs.stdout_path:
|
|
84
|
+
mode = "a" if command.redirs.stdout_append else "w"
|
|
85
|
+
f = open(command.redirs.stdout_path, mode)
|
|
86
|
+
opened_files.append(f)
|
|
87
|
+
shell.stdout = f
|
|
88
|
+
if command.redirs.stderr_path:
|
|
89
|
+
mode = "a" if command.redirs.stderr_append else "w"
|
|
90
|
+
f = open(command.redirs.stderr_path, mode)
|
|
91
|
+
opened_files.append(f)
|
|
92
|
+
shell.stderr = f
|
|
93
|
+
fn = BUILTINS[command.args[0]]
|
|
94
|
+
return fn(command.args[1:], shell)
|
|
95
|
+
finally:
|
|
96
|
+
shell.stdout, shell.stderr = saved_stdout, saved_stderr
|
|
97
|
+
for f in opened_files:
|
|
98
|
+
f.close()
|
|
99
|
+
|
|
100
|
+
# If it's a single external command, resolve it in the parent first.
|
|
101
|
+
# This lets "command not found" be reported through shell.stderr
|
|
102
|
+
# (correct even when shell.stderr is a non-fd Python stream, e.g.
|
|
103
|
+
# in tests) instead of only ever reaching a real fd 2 in a child.
|
|
104
|
+
if len(commands) == 1 and commands[0].args[0] not in BUILTINS:
|
|
105
|
+
if shell.find_executable(commands[0].args[0]) is None:
|
|
106
|
+
print(f"{commands[0].args[0]}: command not found", file=shell.stderr)
|
|
107
|
+
return 127
|
|
108
|
+
|
|
109
|
+
n = len(commands)
|
|
110
|
+
prev_read = None
|
|
111
|
+
pids: list[int] = []
|
|
112
|
+
pgid = None
|
|
113
|
+
|
|
114
|
+
for i, command in enumerate(commands):
|
|
115
|
+
is_last = i == n - 1
|
|
116
|
+
pipe_read, pipe_write = (None, None)
|
|
117
|
+
if not is_last:
|
|
118
|
+
pipe_read, pipe_write = os.pipe()
|
|
119
|
+
|
|
120
|
+
pid = os.fork()
|
|
121
|
+
if pid == 0:
|
|
122
|
+
# ---- child ----
|
|
123
|
+
if pgid is not None:
|
|
124
|
+
os.setpgid(0, pgid)
|
|
125
|
+
else:
|
|
126
|
+
os.setpgid(0, 0)
|
|
127
|
+
|
|
128
|
+
if prev_read is not None:
|
|
129
|
+
os.dup2(prev_read, 0)
|
|
130
|
+
os.close(prev_read)
|
|
131
|
+
if pipe_write is not None:
|
|
132
|
+
os.dup2(pipe_write, 1)
|
|
133
|
+
os.close(pipe_write)
|
|
134
|
+
if pipe_read is not None:
|
|
135
|
+
os.close(pipe_read)
|
|
136
|
+
|
|
137
|
+
stdin_fd, stdout_fd, stderr_fd = _open_redirections(command.redirs)
|
|
138
|
+
if stdin_fd is not None:
|
|
139
|
+
os.dup2(stdin_fd, 0)
|
|
140
|
+
os.close(stdin_fd)
|
|
141
|
+
if stdout_fd is not None:
|
|
142
|
+
os.dup2(stdout_fd, 1)
|
|
143
|
+
os.close(stdout_fd)
|
|
144
|
+
if stderr_fd is not None:
|
|
145
|
+
os.dup2(stderr_fd, 2)
|
|
146
|
+
os.close(stderr_fd)
|
|
147
|
+
|
|
148
|
+
if command.args[0] in BUILTINS:
|
|
149
|
+
_run_builtin_child(command, shell)
|
|
150
|
+
else:
|
|
151
|
+
_exec_external(command, shell)
|
|
152
|
+
os._exit(1) # unreachable
|
|
153
|
+
|
|
154
|
+
# ---- parent ----
|
|
155
|
+
if pgid is None:
|
|
156
|
+
pgid = pid
|
|
157
|
+
try:
|
|
158
|
+
os.setpgid(pid, pgid)
|
|
159
|
+
except OSError:
|
|
160
|
+
pass # child may have already done it / already exited
|
|
161
|
+
pids.append(pid)
|
|
162
|
+
|
|
163
|
+
if prev_read is not None:
|
|
164
|
+
os.close(prev_read)
|
|
165
|
+
if pipe_write is not None:
|
|
166
|
+
os.close(pipe_write)
|
|
167
|
+
prev_read = pipe_read
|
|
168
|
+
|
|
169
|
+
if background:
|
|
170
|
+
job = shell.jobs.add(pgid, pids, raw_line.strip())
|
|
171
|
+
print(f"[{job.job_id}] {pgid}", file=shell.stdout)
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
status = 0
|
|
175
|
+
for pid in pids:
|
|
176
|
+
_, wstatus = os.waitpid(pid, 0)
|
|
177
|
+
if os.WIFEXITED(wstatus):
|
|
178
|
+
status = os.WEXITSTATUS(wstatus)
|
|
179
|
+
elif os.WIFSIGNALED(wstatus):
|
|
180
|
+
status = 128 + os.WTERMSIG(wstatus)
|
|
181
|
+
return status
|
pyshell/history.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Command history: in-memory list plus file persistence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class History:
|
|
8
|
+
def __init__(self, path: str | None = None):
|
|
9
|
+
self.path = path or os.path.expanduser("~/.pyshell_history")
|
|
10
|
+
self.entries: list[str] = []
|
|
11
|
+
self._loaded_count = 0 # entries present at load time, for "append new only"
|
|
12
|
+
|
|
13
|
+
def load(self) -> None:
|
|
14
|
+
if os.path.isfile(self.path):
|
|
15
|
+
with open(self.path, "r", encoding="utf-8", errors="replace") as f:
|
|
16
|
+
self.entries = [line.rstrip("\n") for line in f if line.strip()]
|
|
17
|
+
self._loaded_count = len(self.entries)
|
|
18
|
+
|
|
19
|
+
def add(self, line: str) -> None:
|
|
20
|
+
if line.strip() == "":
|
|
21
|
+
return
|
|
22
|
+
self.entries.append(line)
|
|
23
|
+
|
|
24
|
+
def write_all(self, path: str | None = None) -> None:
|
|
25
|
+
target = path or self.path
|
|
26
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
27
|
+
for entry in self.entries:
|
|
28
|
+
f.write(entry + "\n")
|
|
29
|
+
|
|
30
|
+
def append_new(self, path: str | None = None) -> None:
|
|
31
|
+
"""Append only entries added since load() to the history file."""
|
|
32
|
+
target = path or self.path
|
|
33
|
+
new_entries = self.entries[self._loaded_count:]
|
|
34
|
+
if not new_entries:
|
|
35
|
+
return
|
|
36
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
37
|
+
for entry in new_entries:
|
|
38
|
+
f.write(entry + "\n")
|
|
39
|
+
self._loaded_count = len(self.entries)
|
|
40
|
+
|
|
41
|
+
def show(self, count: int | None = None) -> list[str]:
|
|
42
|
+
if count is None:
|
|
43
|
+
return list(enumerate(self.entries, start=1))
|
|
44
|
+
return list(enumerate(self.entries, start=1))[-count:]
|
pyshell/jobs.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Background job tracking."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Job:
|
|
11
|
+
job_id: int
|
|
12
|
+
pgid: int
|
|
13
|
+
pids: list[int]
|
|
14
|
+
command: str
|
|
15
|
+
status: str = "Running" # Running, Stopped, Done
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JobTable:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self._jobs: dict[int, Job] = {}
|
|
21
|
+
self._next_id = 1
|
|
22
|
+
|
|
23
|
+
def add(self, pgid: int, pids: list[int], command: str) -> Job:
|
|
24
|
+
job = Job(job_id=self._next_id, pgid=pgid, pids=pids, command=command)
|
|
25
|
+
self._jobs[job.job_id] = job
|
|
26
|
+
self._next_id += 1
|
|
27
|
+
return job
|
|
28
|
+
|
|
29
|
+
def get(self, job_id: int) -> Job | None:
|
|
30
|
+
return self._jobs.get(job_id)
|
|
31
|
+
|
|
32
|
+
def latest(self) -> Job | None:
|
|
33
|
+
running = [j for j in self._jobs.values() if j.status != "Done"]
|
|
34
|
+
return running[-1] if running else None
|
|
35
|
+
|
|
36
|
+
def all_sorted(self) -> list[Job]:
|
|
37
|
+
return sorted(self._jobs.values(), key=lambda j: j.job_id)
|
|
38
|
+
|
|
39
|
+
def remove(self, job_id: int) -> None:
|
|
40
|
+
self._jobs.pop(job_id, None)
|
|
41
|
+
|
|
42
|
+
def reap_finished(self) -> list[Job]:
|
|
43
|
+
"""Non-blocking check for finished background jobs. Returns newly-done jobs."""
|
|
44
|
+
finished = []
|
|
45
|
+
for job in list(self._jobs.values()):
|
|
46
|
+
if job.status == "Done":
|
|
47
|
+
continue
|
|
48
|
+
all_done = True
|
|
49
|
+
for pid in job.pids:
|
|
50
|
+
try:
|
|
51
|
+
wpid, _ = os.waitpid(pid, os.WNOHANG)
|
|
52
|
+
if wpid == 0:
|
|
53
|
+
all_done = False
|
|
54
|
+
except ChildProcessError:
|
|
55
|
+
pass # already reaped
|
|
56
|
+
if all_done:
|
|
57
|
+
job.status = "Done"
|
|
58
|
+
finished.append(job)
|
|
59
|
+
return finished
|
|
60
|
+
|
|
61
|
+
def format_line(self, job: Job, latest_id: int | None = None) -> str:
|
|
62
|
+
marker = "+" if job.job_id == latest_id else " "
|
|
63
|
+
return f"[{job.job_id}]{marker} {job.status:<10} {job.command}"
|
|
64
|
+
|
|
65
|
+
def continue_job(self, job: Job, foreground: bool) -> None:
|
|
66
|
+
job.status = "Running"
|
|
67
|
+
try:
|
|
68
|
+
os.killpg(job.pgid, signal.SIGCONT)
|
|
69
|
+
except ProcessLookupError:
|
|
70
|
+
job.status = "Done"
|
|
71
|
+
return
|
|
72
|
+
if foreground:
|
|
73
|
+
for pid in job.pids:
|
|
74
|
+
try:
|
|
75
|
+
os.waitpid(pid, 0)
|
|
76
|
+
except ChildProcessError:
|
|
77
|
+
pass
|
|
78
|
+
job.status = "Done"
|
pyshell/main.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Entry point: `python -m pyshell` or the installed `pyshell` command."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from .shell import Shell
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> int:
|
|
8
|
+
shell = Shell()
|
|
9
|
+
try:
|
|
10
|
+
return shell.run()
|
|
11
|
+
except SystemExit as e:
|
|
12
|
+
shell.history.append_new()
|
|
13
|
+
return e.code or 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
sys.exit(main())
|
pyshell/parser.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tokenizing and parsing for pyshell.
|
|
3
|
+
|
|
4
|
+
Responsibilities:
|
|
5
|
+
- split_pipeline: split a raw line into pipeline stages on unquoted `|`
|
|
6
|
+
- tokenize: turn a single stage into word tokens, honoring
|
|
7
|
+
single quotes, double quotes, and backslash escapes
|
|
8
|
+
- extract_redirections: pull `>`, `>>`, `2>`, `2>>`, `<` out of a token
|
|
9
|
+
list, returning the remaining command tokens plus
|
|
10
|
+
a Redirections object describing file targets
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
_VAR_RE = re.compile(r"\$(\{(\w+)\}|(\w+)|\?)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _expand_vars(text: str) -> str:
|
|
22
|
+
"""Expand $VAR, ${VAR}, and $? using the current environment."""
|
|
23
|
+
def repl(m: re.Match) -> str:
|
|
24
|
+
if m.group(0) == "$?":
|
|
25
|
+
return os.environ.get("?", "0")
|
|
26
|
+
name = m.group(2) or m.group(3)
|
|
27
|
+
return os.environ.get(name, "")
|
|
28
|
+
|
|
29
|
+
return _VAR_RE.sub(repl, text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ParseError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def split_pipeline(line: str) -> list[str]:
|
|
37
|
+
"""Split a line into stages on unquoted, unescaped `|`.
|
|
38
|
+
|
|
39
|
+
`echo "a|b"` must NOT be split; `echo a | wc` must be split into
|
|
40
|
+
two stages: 'echo a' and ' wc'.
|
|
41
|
+
"""
|
|
42
|
+
stages = []
|
|
43
|
+
current = []
|
|
44
|
+
in_single = in_double = False
|
|
45
|
+
i = 0
|
|
46
|
+
while i < len(line):
|
|
47
|
+
c = line[i]
|
|
48
|
+
if in_single:
|
|
49
|
+
current.append(c)
|
|
50
|
+
if c == "'":
|
|
51
|
+
in_single = False
|
|
52
|
+
elif in_double:
|
|
53
|
+
current.append(c)
|
|
54
|
+
if c == '"':
|
|
55
|
+
in_double = False
|
|
56
|
+
elif c == "\\" and i + 1 < len(line):
|
|
57
|
+
current.append(line[i + 1])
|
|
58
|
+
i += 1
|
|
59
|
+
else:
|
|
60
|
+
if c == "'":
|
|
61
|
+
in_single = True
|
|
62
|
+
current.append(c)
|
|
63
|
+
elif c == '"':
|
|
64
|
+
in_double = True
|
|
65
|
+
current.append(c)
|
|
66
|
+
elif c == "\\" and i + 1 < len(line):
|
|
67
|
+
current.append(c)
|
|
68
|
+
current.append(line[i + 1])
|
|
69
|
+
i += 1
|
|
70
|
+
elif c == "|":
|
|
71
|
+
stages.append("".join(current))
|
|
72
|
+
current = []
|
|
73
|
+
else:
|
|
74
|
+
current.append(c)
|
|
75
|
+
i += 1
|
|
76
|
+
stages.append("".join(current))
|
|
77
|
+
if in_single or in_double:
|
|
78
|
+
raise ParseError("unmatched quote")
|
|
79
|
+
return stages
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tokenize(line: str) -> list[str]:
|
|
83
|
+
"""Split a single command stage into word tokens.
|
|
84
|
+
|
|
85
|
+
Rules (bash-like subset):
|
|
86
|
+
- Outside quotes, backslash escapes the next character literally.
|
|
87
|
+
- Inside single quotes, everything is literal (no escapes).
|
|
88
|
+
- Inside double quotes, backslash only escapes \\, $, ", ` and
|
|
89
|
+
newline; otherwise the backslash is kept literally.
|
|
90
|
+
- Unquoted whitespace separates tokens; runs of whitespace collapse.
|
|
91
|
+
- Adjacent quoted/unquoted chunks glue into a single token, e.g.
|
|
92
|
+
foo"bar baz"qux -> one token: foobar bazqux
|
|
93
|
+
"""
|
|
94
|
+
tokens: list[str] = []
|
|
95
|
+
# A token is built from segments; each segment is either literal
|
|
96
|
+
# (single-quoted -> never variable-expanded) or expandable
|
|
97
|
+
# (unquoted / double-quoted -> $VAR gets expanded).
|
|
98
|
+
segments: list[tuple[str, bool]] = []
|
|
99
|
+
current: list[str] = []
|
|
100
|
+
expandable = True
|
|
101
|
+
have_token = False
|
|
102
|
+
in_single = in_double = False
|
|
103
|
+
i = 0
|
|
104
|
+
n = len(line)
|
|
105
|
+
|
|
106
|
+
def flush_segment():
|
|
107
|
+
nonlocal current
|
|
108
|
+
if current:
|
|
109
|
+
segments.append(("".join(current), expandable))
|
|
110
|
+
current = []
|
|
111
|
+
|
|
112
|
+
while i < n:
|
|
113
|
+
c = line[i]
|
|
114
|
+
|
|
115
|
+
if in_single:
|
|
116
|
+
if c == "'":
|
|
117
|
+
in_single = False
|
|
118
|
+
flush_segment()
|
|
119
|
+
expandable = True
|
|
120
|
+
else:
|
|
121
|
+
current.append(c)
|
|
122
|
+
|
|
123
|
+
elif in_double:
|
|
124
|
+
if c == '"':
|
|
125
|
+
in_double = False
|
|
126
|
+
elif c == "\\" and i + 1 < n and line[i + 1] in ('\\', "$", '"', "`", "\n"):
|
|
127
|
+
current.append(line[i + 1])
|
|
128
|
+
i += 1
|
|
129
|
+
else:
|
|
130
|
+
current.append(c)
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
if c.isspace():
|
|
134
|
+
if have_token:
|
|
135
|
+
flush_segment()
|
|
136
|
+
tokens.append("".join(
|
|
137
|
+
text if not exp else _expand_vars(text)
|
|
138
|
+
for text, exp in segments
|
|
139
|
+
))
|
|
140
|
+
segments = []
|
|
141
|
+
have_token = False
|
|
142
|
+
i += 1
|
|
143
|
+
continue
|
|
144
|
+
elif c == "'":
|
|
145
|
+
flush_segment()
|
|
146
|
+
in_single = True
|
|
147
|
+
expandable = False
|
|
148
|
+
have_token = True
|
|
149
|
+
elif c == '"':
|
|
150
|
+
flush_segment()
|
|
151
|
+
in_double = True
|
|
152
|
+
expandable = True
|
|
153
|
+
have_token = True
|
|
154
|
+
elif c == "\\" and i + 1 < n:
|
|
155
|
+
current.append(line[i + 1])
|
|
156
|
+
have_token = True
|
|
157
|
+
i += 1
|
|
158
|
+
else:
|
|
159
|
+
current.append(c)
|
|
160
|
+
have_token = True
|
|
161
|
+
|
|
162
|
+
i += 1
|
|
163
|
+
|
|
164
|
+
if in_single or in_double:
|
|
165
|
+
raise ParseError("unmatched quote")
|
|
166
|
+
|
|
167
|
+
if have_token:
|
|
168
|
+
flush_segment()
|
|
169
|
+
tokens.append("".join(
|
|
170
|
+
text if not exp else _expand_vars(text)
|
|
171
|
+
for text, exp in segments
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
return tokens
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class Redirections:
|
|
179
|
+
stdout_path: str | None = None
|
|
180
|
+
stdout_append: bool = False
|
|
181
|
+
stderr_path: str | None = None
|
|
182
|
+
stderr_append: bool = False
|
|
183
|
+
stdin_path: str | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
_REDIR_OPS = {
|
|
187
|
+
">": ("stdout", False),
|
|
188
|
+
"1>": ("stdout", False),
|
|
189
|
+
">>": ("stdout", True),
|
|
190
|
+
"1>>": ("stdout", True),
|
|
191
|
+
"2>": ("stderr", False),
|
|
192
|
+
"2>>": ("stderr", True),
|
|
193
|
+
"<": ("stdin", False),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_redirections(tokens: list[str]) -> tuple[list[str], Redirections]:
|
|
198
|
+
"""Remove redirection operators + targets from a token list."""
|
|
199
|
+
out_tokens: list[str] = []
|
|
200
|
+
redirs = Redirections()
|
|
201
|
+
i = 0
|
|
202
|
+
while i < len(tokens):
|
|
203
|
+
tok = tokens[i]
|
|
204
|
+
if tok in _REDIR_OPS:
|
|
205
|
+
if i + 1 >= len(tokens):
|
|
206
|
+
raise ParseError(f"syntax error near unexpected token `newline' after `{tok}'")
|
|
207
|
+
target = tokens[i + 1]
|
|
208
|
+
kind, append = _REDIR_OPS[tok]
|
|
209
|
+
if kind == "stdout":
|
|
210
|
+
redirs.stdout_path = target
|
|
211
|
+
redirs.stdout_append = append
|
|
212
|
+
elif kind == "stderr":
|
|
213
|
+
redirs.stderr_path = target
|
|
214
|
+
redirs.stderr_append = append
|
|
215
|
+
else:
|
|
216
|
+
redirs.stdin_path = target
|
|
217
|
+
i += 2
|
|
218
|
+
else:
|
|
219
|
+
out_tokens.append(tok)
|
|
220
|
+
i += 1
|
|
221
|
+
return out_tokens, redirs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class Command:
|
|
226
|
+
args: list[str]
|
|
227
|
+
redirs: Redirections
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def parse_line(line: str) -> tuple[list[Command], bool]:
|
|
231
|
+
"""Parse a full input line into a pipeline of Commands.
|
|
232
|
+
|
|
233
|
+
Returns (commands, background) where background is True if the
|
|
234
|
+
line ended with an unquoted `&`.
|
|
235
|
+
"""
|
|
236
|
+
stripped = line.rstrip()
|
|
237
|
+
background = False
|
|
238
|
+
# Detect trailing unquoted `&`
|
|
239
|
+
stage_check = split_pipeline(stripped)
|
|
240
|
+
last = stage_check[-1].rstrip()
|
|
241
|
+
if last.endswith("&") and not last.endswith("\\&"):
|
|
242
|
+
background = True
|
|
243
|
+
stripped = stripped.rstrip()[:-1].rstrip()
|
|
244
|
+
|
|
245
|
+
commands = []
|
|
246
|
+
for stage in split_pipeline(stripped):
|
|
247
|
+
stage = stage.strip()
|
|
248
|
+
if stage == "":
|
|
249
|
+
continue
|
|
250
|
+
tokens = tokenize(stage)
|
|
251
|
+
cmd_tokens, redirs = extract_redirections(tokens)
|
|
252
|
+
if not cmd_tokens:
|
|
253
|
+
raise ParseError("syntax error: empty command")
|
|
254
|
+
commands.append(Command(args=cmd_tokens, redirs=redirs))
|
|
255
|
+
|
|
256
|
+
return commands, background
|
pyshell/shell.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The Shell class: owns state and runs the read-eval-print loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import banner, completion
|
|
8
|
+
from .executor import run_pipeline
|
|
9
|
+
from .history import History
|
|
10
|
+
from .jobs import JobTable
|
|
11
|
+
from .parser import ParseError, parse_line
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Shell:
|
|
15
|
+
def __init__(self, history_path: str | None = None):
|
|
16
|
+
self.jobs = JobTable()
|
|
17
|
+
self.history = History(path=history_path)
|
|
18
|
+
self.stdout = sys.stdout
|
|
19
|
+
self.stderr = sys.stderr
|
|
20
|
+
self.last_status = 0
|
|
21
|
+
self._path_cache: dict[str, str | None] = {}
|
|
22
|
+
|
|
23
|
+
# -- PATH lookup -----------------------------------------------------
|
|
24
|
+
def find_executable(self, cmd: str) -> str | None:
|
|
25
|
+
if os.sep in cmd:
|
|
26
|
+
return cmd if os.path.isfile(cmd) and os.access(cmd, os.X_OK) else None
|
|
27
|
+
if cmd in self._path_cache:
|
|
28
|
+
return self._path_cache[cmd]
|
|
29
|
+
result = None
|
|
30
|
+
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
31
|
+
if not directory:
|
|
32
|
+
continue
|
|
33
|
+
candidate = os.path.join(directory, cmd)
|
|
34
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
35
|
+
result = candidate
|
|
36
|
+
break
|
|
37
|
+
self._path_cache[cmd] = result
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
# -- prompt ------------------------------------------------------------
|
|
41
|
+
def prompt(self) -> str:
|
|
42
|
+
cwd = os.getcwd()
|
|
43
|
+
home = os.path.expanduser("~")
|
|
44
|
+
if cwd.startswith(home):
|
|
45
|
+
cwd = "~" + cwd[len(home):]
|
|
46
|
+
return f"{os.path.basename(cwd) and cwd}$ "
|
|
47
|
+
|
|
48
|
+
# -- main loop -----------------------------------------------------
|
|
49
|
+
def run(self) -> int:
|
|
50
|
+
self.history.load()
|
|
51
|
+
completion.install()
|
|
52
|
+
banner.show()
|
|
53
|
+
try:
|
|
54
|
+
while True:
|
|
55
|
+
self.jobs.reap_finished()
|
|
56
|
+
try:
|
|
57
|
+
sys.stdout.write(self.prompt())
|
|
58
|
+
sys.stdout.flush()
|
|
59
|
+
line = input()
|
|
60
|
+
except EOFError:
|
|
61
|
+
print()
|
|
62
|
+
break
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
print()
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if line.strip() == "":
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
self.history.add(line)
|
|
71
|
+
self.execute(line)
|
|
72
|
+
os.environ["?"] = str(self.last_status)
|
|
73
|
+
finally:
|
|
74
|
+
self.history.append_new()
|
|
75
|
+
return self.last_status
|
|
76
|
+
|
|
77
|
+
def execute(self, line: str) -> int:
|
|
78
|
+
try:
|
|
79
|
+
commands, background = parse_line(line)
|
|
80
|
+
except ParseError as e:
|
|
81
|
+
print(f"pyshell: {e}", file=self.stderr)
|
|
82
|
+
self.last_status = 2
|
|
83
|
+
return self.last_status
|
|
84
|
+
|
|
85
|
+
if not commands:
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
self.last_status = run_pipeline(commands, background, line, self)
|
|
90
|
+
except SystemExit:
|
|
91
|
+
raise
|
|
92
|
+
except OSError as e:
|
|
93
|
+
print(f"pyshell: {e}", file=self.stderr)
|
|
94
|
+
self.last_status = 1
|
|
95
|
+
return self.last_status
|