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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: my_pyshell
3
+ Version: 0.1.0
4
+ Summary: A custom Unix-like shell written in Python
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Dynamic: license-file
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyshell = pyshell.main:main
@@ -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
@@ -0,0 +1,5 @@
1
+ from .main import main
2
+ import sys
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
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