my-pyshell 0.1.0__tar.gz
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/LICENSE +21 -0
- my_pyshell-0.1.0/PKG-INFO +7 -0
- my_pyshell-0.1.0/README.md +89 -0
- my_pyshell-0.1.0/my_pyshell.egg-info/PKG-INFO +7 -0
- my_pyshell-0.1.0/my_pyshell.egg-info/SOURCES.txt +22 -0
- my_pyshell-0.1.0/my_pyshell.egg-info/dependency_links.txt +1 -0
- my_pyshell-0.1.0/my_pyshell.egg-info/entry_points.txt +2 -0
- my_pyshell-0.1.0/my_pyshell.egg-info/top_level.txt +1 -0
- my_pyshell-0.1.0/pyproject.toml +12 -0
- my_pyshell-0.1.0/pyshell/__init__.py +0 -0
- my_pyshell-0.1.0/pyshell/__main__.py +5 -0
- my_pyshell-0.1.0/pyshell/banner.py +87 -0
- my_pyshell-0.1.0/pyshell/builtins.py +173 -0
- my_pyshell-0.1.0/pyshell/completion.py +67 -0
- my_pyshell-0.1.0/pyshell/executor.py +181 -0
- my_pyshell-0.1.0/pyshell/history.py +44 -0
- my_pyshell-0.1.0/pyshell/jobs.py +78 -0
- my_pyshell-0.1.0/pyshell/main.py +17 -0
- my_pyshell-0.1.0/pyshell/parser.py +256 -0
- my_pyshell-0.1.0/pyshell/shell.py +95 -0
- my_pyshell-0.1.0/setup.cfg +4 -0
- my_pyshell-0.1.0/tests/test_banner.py +38 -0
- my_pyshell-0.1.0/tests/test_parser.py +90 -0
- my_pyshell-0.1.0/tests/test_shell.py +94 -0
my_pyshell-0.1.0/LICENSE
ADDED
|
@@ -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,89 @@
|
|
|
1
|
+
# pyshell
|
|
2
|
+
|
|
3
|
+
A Unix-like shell written from scratch in Python — no `shlex`, no
|
|
4
|
+
`subprocess`, no `readline` shortcuts for the core logic. It has its
|
|
5
|
+
own tokenizer, its own pipeline/redirection handling, and its own job
|
|
6
|
+
control built on raw `os.fork`/`os.exec`/`os.pipe`.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Animated startup banner**: a gradient ASCII-art splash on launch
|
|
11
|
+
(auto-skips when piped/scripted/tested, or via `PYSHELL_NO_BANNER=1`)
|
|
12
|
+
- **REPL** with a `cwd$ ` prompt
|
|
13
|
+
- **Builtins**: `cd`, `pwd`, `echo`, `exit`, `type`, `export`, `unset`,
|
|
14
|
+
`env`, `history`, `jobs`, `fg`, `bg`, `help`
|
|
15
|
+
- **Quoting**: single quotes (fully literal), double quotes (with
|
|
16
|
+
`\`, `$`, `"` escapes), backslash escaping outside quotes
|
|
17
|
+
- **Variable expansion**: `$VAR`, `${VAR}`, `$?` (last exit status)
|
|
18
|
+
- **Redirection**: `>`, `>>`, `2>`, `2>>`, `<`
|
|
19
|
+
- **Pipelines**: `cmd1 | cmd2 | cmd3`, builtins and externals can be
|
|
20
|
+
mixed in a pipeline
|
|
21
|
+
- **Background jobs**: `cmd &`, plus `jobs` / `fg` / `bg`, with real
|
|
22
|
+
process groups so signals target the whole pipeline
|
|
23
|
+
- **History**: in-memory during the session, persisted to
|
|
24
|
+
`~/.pyshell_history` on exit (loaded back in on the next start)
|
|
25
|
+
- **Tab completion**: builtins + `$PATH` executables for the first
|
|
26
|
+
word, filenames for the rest
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone <your-repo-url> pyshell
|
|
32
|
+
cd pyshell
|
|
33
|
+
python3 -m pyshell
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or install it as a command:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e .
|
|
40
|
+
pyshell
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Architecture
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
pyshell/
|
|
47
|
+
├── parser.py # tokenize, split_pipeline, extract_redirections, parse_line
|
|
48
|
+
├── builtins.py # BUILTINS: name -> fn(args, shell) -> exit_status
|
|
49
|
+
├── executor.py # run_pipeline: forking, piping, redirection, backgrounding
|
|
50
|
+
├── jobs.py # JobTable: track/reap/continue background process groups
|
|
51
|
+
├── history.py # History: in-memory list + file persistence
|
|
52
|
+
├── completion.py # readline hook for tab completion
|
|
53
|
+
├── banner.py # animated ASCII-art startup splash
|
|
54
|
+
├── shell.py # Shell class: owns state, runs the REPL loop
|
|
55
|
+
└── main.py # entry point
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Design principle:** parsing, execution, and state are separate
|
|
59
|
+
modules. A single non-backgrounded builtin runs directly in the
|
|
60
|
+
shell's own process (so `cd` actually changes the shell's directory,
|
|
61
|
+
and `exit` actually exits). Anything else — external programs, piped
|
|
62
|
+
stages, or backgrounded commands — runs in a forked child, wired up
|
|
63
|
+
with `os.pipe()`/`os.dup2()` and grouped into a process group with
|
|
64
|
+
`os.setpgid()` so `jobs`/`fg`/`bg` can control the whole pipeline.
|
|
65
|
+
|
|
66
|
+
## Running tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install pytest
|
|
70
|
+
pytest -q
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Known limitations / roadmap
|
|
74
|
+
|
|
75
|
+
These map to natural next commits if you want to keep extending it:
|
|
76
|
+
|
|
77
|
+
- No globbing (`*.txt` expansion)
|
|
78
|
+
- No `&&` / `||` / `;` command chaining
|
|
79
|
+
- No here-docs (`<<`)
|
|
80
|
+
- No terminal-level job control (`Ctrl-Z` to suspend, `tcsetpgrp` for
|
|
81
|
+
real foreground/background terminal ownership) — `fg`/`bg` work via
|
|
82
|
+
`SIGCONT` and `waitpid` but don't hand over the controlling terminal
|
|
83
|
+
- No command substitution (`` `cmd` `` / `$(cmd)`)
|
|
84
|
+
- No aliasing or shell functions
|
|
85
|
+
- No scripting (`if`/`for`/`while`, reading from a script file)
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT — do whatever you want with it.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
my_pyshell.egg-info/PKG-INFO
|
|
5
|
+
my_pyshell.egg-info/SOURCES.txt
|
|
6
|
+
my_pyshell.egg-info/dependency_links.txt
|
|
7
|
+
my_pyshell.egg-info/entry_points.txt
|
|
8
|
+
my_pyshell.egg-info/top_level.txt
|
|
9
|
+
pyshell/__init__.py
|
|
10
|
+
pyshell/__main__.py
|
|
11
|
+
pyshell/banner.py
|
|
12
|
+
pyshell/builtins.py
|
|
13
|
+
pyshell/completion.py
|
|
14
|
+
pyshell/executor.py
|
|
15
|
+
pyshell/history.py
|
|
16
|
+
pyshell/jobs.py
|
|
17
|
+
pyshell/main.py
|
|
18
|
+
pyshell/parser.py
|
|
19
|
+
pyshell/shell.py
|
|
20
|
+
tests/test_banner.py
|
|
21
|
+
tests/test_parser.py
|
|
22
|
+
tests/test_shell.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyshell
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "my_pyshell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A custom Unix-like shell written in Python"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
|
|
7
|
+
[project.scripts]
|
|
8
|
+
pyshell = "pyshell.main:main"
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["setuptools>=61.0"]
|
|
12
|
+
build-backend = "setuptools.build_meta"
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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
|
+
}
|
|
@@ -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")
|
|
@@ -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
|