Skip to content

Commit 31de4b8

Browse files
committed
adds process module
1 parent 65401a9 commit 31de4b8

File tree

1 file changed

+204
-0
lines changed

1 file changed

+204
-0
lines changed

ngwidgets/shell.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""
2+
Created on 2025-05-14
3+
4+
@author: wf
5+
"""
6+
7+
import io
8+
import os
9+
import subprocess
10+
import sys
11+
import threading
12+
13+
14+
class StreamTee:
15+
"""
16+
Tees a single input stream to both a mirror and a capture buffer.
17+
"""
18+
19+
def __init__(self, source, mirror, buffer, tee=True):
20+
self.source = source
21+
self.mirror = mirror
22+
self.buffer = buffer
23+
self.tee = tee
24+
self.thread = threading.Thread(target=self._run, daemon=True)
25+
26+
def _run(self):
27+
for line in iter(self.source.readline, ""):
28+
if self.tee:
29+
self.mirror.write(line)
30+
self.mirror.flush()
31+
self.buffer.write(line)
32+
self.source.close()
33+
34+
def start(self):
35+
self.thread.start()
36+
37+
def join(self):
38+
self.thread.join()
39+
40+
41+
class SysTee:
42+
"""
43+
Tee sys.stdout and sys.stderr to a logfile while preserving original output.
44+
"""
45+
46+
def __init__(self, log_path: str):
47+
self.logfile = open(log_path, "a")
48+
self.original_stdout = sys.stdout
49+
self.original_stderr = sys.stderr
50+
sys.stdout = self
51+
sys.stderr = self
52+
53+
def write(self, data):
54+
self.original_stdout.write(data)
55+
self.logfile.write(data)
56+
57+
def flush(self):
58+
self.original_stdout.flush()
59+
self.logfile.flush()
60+
61+
def close(self):
62+
sys.stdout = self.original_stdout
63+
sys.stderr = self.original_stderr
64+
self.logfile.close()
65+
66+
67+
class StdTee:
68+
"""
69+
Manages teeing for both stdout and stderr using StreamTee instances.
70+
Captures output in instance variables.
71+
"""
72+
73+
def __init__(self, process, tee=True):
74+
self.stdout_buffer = io.StringIO()
75+
self.stderr_buffer = io.StringIO()
76+
self.out_tee = StreamTee(process.stdout, sys.stdout, self.stdout_buffer, tee)
77+
self.err_tee = StreamTee(process.stderr, sys.stderr, self.stderr_buffer, tee)
78+
79+
def start(self):
80+
self.out_tee.start()
81+
self.err_tee.start()
82+
83+
def join(self):
84+
self.out_tee.join()
85+
self.err_tee.join()
86+
87+
@classmethod
88+
def run(cls, process, tee=True):
89+
"""
90+
Run teeing and capture for the given process.
91+
Returns a StdTee instance with stdout/stderr captured.
92+
"""
93+
std_tee = cls(process, tee=tee)
94+
std_tee.start()
95+
std_tee.join()
96+
return std_tee
97+
98+
99+
class Shell:
100+
"""
101+
Runs commands with environment from profile
102+
"""
103+
104+
def __init__(self, profile=None, shell_path: str = None):
105+
"""
106+
Initialize shell with optional profile
107+
108+
Args:
109+
profile: Path to profile file to source e.g. ~/.zprofile
110+
shell_path: the shell_path e.g. /bin/zsh
111+
"""
112+
self.profile = profile
113+
self.shell_path = shell_path
114+
if self.shell_path is None:
115+
self.shell_path = os.environ.get("SHELL", "/bin/bash")
116+
self.shell_name = os.path.basename(self.shell_path)
117+
if self.profile is None:
118+
self.profile = self.find_profile()
119+
120+
def find_profile(self) -> str:
121+
"""
122+
Find the appropriate profile file for the current shell
123+
124+
Searches for the profile file corresponding to the shell_name
125+
in the user's home directory.
126+
127+
Returns:
128+
str: Path to the profile file or None if not found
129+
"""
130+
profile = None
131+
home = os.path.expanduser("~")
132+
# Try common profile files
133+
profiles = {"zsh": ".zprofile", "bash": ".bash_profile", "sh": ".profile"}
134+
if self.shell_name in profiles:
135+
profile_name = profiles[self.shell_name]
136+
path = os.path.join(home, profile_name)
137+
if os.path.exists(path):
138+
profile = path
139+
return profile
140+
141+
@classmethod
142+
def ofArgs(cls, args):
143+
"""
144+
Create Shell from command line args
145+
146+
Args:
147+
args: Arguments with optional profile
148+
149+
Returns:
150+
Shell: Configured Shell
151+
"""
152+
# Use explicit profile or detect
153+
profile = getattr(args, "profile", None)
154+
shell = cls(profile=profile)
155+
return shell
156+
157+
def run(
158+
self, cmd, text=True, debug=False, tee=False
159+
) -> subprocess.CompletedProcess:
160+
"""
161+
Run command with profile, always capturing output and optionally teeing it.
162+
163+
Args:
164+
cmd: Command to run
165+
text: Text mode for subprocess I/O
166+
debug: Print the command to be run
167+
tee: If True, also print output live while capturing
168+
169+
Returns:
170+
subprocess.CompletedProcess
171+
"""
172+
shell_cmd = f"source {self.profile} && {cmd}" if self.profile else cmd
173+
174+
if debug:
175+
print(f"Running: {shell_cmd}")
176+
177+
popen_process = subprocess.Popen(
178+
[self.shell_path, "-c", shell_cmd],
179+
stdout=subprocess.PIPE,
180+
stderr=subprocess.PIPE,
181+
text=text,
182+
)
183+
184+
std_tee = StdTee.run(popen_process, tee=tee)
185+
returncode = popen_process.wait()
186+
187+
process = subprocess.CompletedProcess(
188+
args=popen_process.args,
189+
returncode=returncode,
190+
stdout=std_tee.stdout_buffer.getvalue(),
191+
stderr=std_tee.stderr_buffer.getvalue(),
192+
)
193+
194+
if process.returncode != 0:
195+
if debug:
196+
msg = f"""{process.args} failed:
197+
returncode: {process.returncode}
198+
stdout : {process.stdout.strip()}
199+
stderr : {process.stderr.strip()}
200+
"""
201+
print(msg, file=sys.stderr)
202+
pass
203+
204+
return process

0 commit comments

Comments
 (0)