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.
@@ -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,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,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,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,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,2 @@
1
+ [console_scripts]
2
+ pyshell = pyshell.main:main
@@ -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,5 @@
1
+ from .main import main
2
+ import sys
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -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