bashautom 0.1.1__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.
bashautom/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .session import Session, CommandResult
2
+ from .manager import SessionManager
3
+
4
+ __all__ = ["Session", "CommandResult", "SessionManager"]
5
+ __version__ = "0.1.0"
bashautom/manager.py ADDED
@@ -0,0 +1,90 @@
1
+ from typing import Optional
2
+ from .session import Session
3
+
4
+
5
+ class SessionManager:
6
+ """Named session pool. Create, get, and cleanup multiple sessions."""
7
+ def __init__(self):
8
+ self._sessions: dict[str, Session] = {}
9
+
10
+ def create(
11
+ self,
12
+ name: str,
13
+ shell: str = "/bin/bash",
14
+ env: Optional[dict] = None,
15
+ cwd: Optional[str] = None,
16
+ ) -> Session:
17
+ """Create a named session. Raises KeyError if name exists."""
18
+ if name in self._sessions:
19
+ raise ValueError(f"Session '{name}' already exists. Use get() or close it first.")
20
+
21
+ session = Session(shell=shell, env=env, cwd=cwd, name=name)
22
+ self._sessions[name] = session
23
+ return session
24
+
25
+ def get(self, name: str) -> Session:
26
+ """Get session by name. Raises KeyError if not found."""
27
+ if name not in self._sessions:
28
+ raise KeyError(f"No session named '{name}'. Available: {list(self._sessions.keys())}")
29
+ return self._sessions[name]
30
+
31
+ def get_or_create(
32
+ self,
33
+ name: str,
34
+ shell: str = "/bin/bash",
35
+ env: Optional[dict] = None,
36
+ cwd: Optional[str] = None,
37
+ ) -> Session:
38
+ """Get or create a session."""
39
+ if name in self._sessions and self._sessions[name].alive:
40
+ return self._sessions[name]
41
+ # Clean up dead session if it exists
42
+ if name in self._sessions:
43
+ self._sessions[name].close()
44
+ del self._sessions[name]
45
+ return self.create(name, shell=shell, env=env, cwd=cwd)
46
+
47
+ def close(self, name: str) -> None:
48
+ """Close and remove a specific session."""
49
+ if name in self._sessions:
50
+ self._sessions[name].close()
51
+ del self._sessions[name]
52
+
53
+ def close_all(self) -> None:
54
+ """Close everything."""
55
+ for session in self._sessions.values():
56
+ try:
57
+ session.close()
58
+ except Exception:
59
+ pass
60
+ self._sessions.clear()
61
+
62
+ @property
63
+ def names(self) -> list[str]:
64
+ """List all session names."""
65
+ return list(self._sessions.keys())
66
+
67
+ @property
68
+ def active(self) -> list[Session]:
69
+ """List all alive sessions."""
70
+ return [s for s in self._sessions.values() if s.alive]
71
+
72
+ def __contains__(self, name: str) -> bool:
73
+ return name in self._sessions
74
+
75
+ def __len__(self) -> int:
76
+ return len(self._sessions)
77
+
78
+ def __getitem__(self, name: str) -> Session:
79
+ return self.get(name)
80
+
81
+ def __enter__(self) -> "SessionManager":
82
+ return self
83
+
84
+ def __exit__(self, *args) -> None:
85
+ self.close_all()
86
+
87
+ def __repr__(self) -> str:
88
+ alive = len(self.active)
89
+ total = len(self._sessions)
90
+ return f"<SessionManager sessions={total} alive={alive}>"
bashautom/session.py ADDED
@@ -0,0 +1,334 @@
1
+ import os
2
+ import subprocess
3
+ import selectors
4
+ import time
5
+ import secrets
6
+ import signal
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, Callable
9
+
10
+
11
+ @dataclass
12
+ class CommandResult:
13
+ """Output of Session.execute()."""
14
+ command: str
15
+ stdout: str
16
+ stderr: str
17
+ exit_code: int
18
+ duration: float
19
+ timed_out: bool = False
20
+
21
+ @property
22
+ def success(self) -> bool:
23
+ return self.exit_code == 0 and not self.timed_out
24
+
25
+ def __repr__(self) -> str:
26
+ status = "OK" if self.success else f"FAIL({self.exit_code})"
27
+ return f"<CommandResult [{status}] {self.command!r} ({self.duration:.2f}s)>"
28
+
29
+
30
+ @dataclass
31
+ class StreamEvent:
32
+ """Chunk of output from a streaming execute() call."""
33
+ stream: str # "stdout" or "stderr"
34
+ data: str # the chunk of text
35
+ timestamp: float # time.monotonic()
36
+
37
+
38
+ class SessionError(Exception):
39
+ """Raised when the session is in an invalid state."""
40
+ pass
41
+
42
+
43
+ class Session:
44
+ """Persistent bash session. State carries over between execute() calls."""
45
+ _TOKEN_PREFIX = "__BASHAUTOM_END_krjyngsczkvmlzqaoxpgudjhkejvbowc__"
46
+
47
+ def __init__(
48
+ self,
49
+ shell: str = "/bin/bash",
50
+ env: Optional[dict] = None,
51
+ cwd: Optional[str] = None,
52
+ name: Optional[str] = None,
53
+ ):
54
+ self.shell = shell
55
+ self.name = name or f"session-{secrets.token_hex(4)}"
56
+ self._closed = False
57
+
58
+ # Spawn the bash process (non-interactive to avoid input echo on stderr)
59
+ self._proc = subprocess.Popen(
60
+ [shell, "--norc", "--noprofile"],
61
+ stdin=subprocess.PIPE,
62
+ stdout=subprocess.PIPE,
63
+ stderr=subprocess.PIPE,
64
+ env=env,
65
+ cwd=cwd,
66
+ # Ensure line-buffered isn't forced; we handle our own buffering
67
+ bufsize=0,
68
+ # New process group so we can signal children
69
+ preexec_fn=os.setsid,
70
+ )
71
+
72
+ # Setup selector for non-blocking reads on stdout and stderr
73
+ self._sel = selectors.DefaultSelector()
74
+ os.set_blocking(self._proc.stdout.fileno(), False)
75
+ os.set_blocking(self._proc.stderr.fileno(), False)
76
+ self._sel.register(self._proc.stdout, selectors.EVENT_READ, "stdout")
77
+ self._sel.register(self._proc.stderr, selectors.EVENT_READ, "stderr")
78
+
79
+ self._drain(timeout=0.5)
80
+
81
+ # Setup: trap SIGINT in bash with a no-op handler so that:
82
+ # - bash itself survives SIGINT (doesn't exit)
83
+ # - child processes still get default SIGINT behavior (they die)
84
+ # This is crucial for timeout support, we SIGINT the process group,
85
+ # which kills the running child but keeps bash alive for the next command.
86
+ self._proc.stdin.write(b"trap : INT\n")
87
+ self._proc.stdin.flush()
88
+ self._drain(timeout=0.2)
89
+
90
+ def _generate_token(self) -> str:
91
+ """Generate a unique end-of-command token."""
92
+ return f"{self._TOKEN_PREFIX}{secrets.token_hex(8)}"
93
+
94
+ def _drain(self, timeout: float = 0.1) -> tuple[str, str]:
95
+ """Drain all pending output from stdout/stderr."""
96
+ stdout_buf = []
97
+ stderr_buf = []
98
+ deadline = time.monotonic() + timeout
99
+
100
+ while True:
101
+ remaining = deadline - time.monotonic()
102
+ if remaining <= 0:
103
+ break
104
+
105
+ # FIXME: _drain can miss output if process writes in bursts > 50ms apart
106
+ events = self._sel.select(timeout=min(remaining, 0.05))
107
+ if not events:
108
+ break
109
+
110
+ for key, _ in events:
111
+ chunk = key.fileobj.read(65536)
112
+ if chunk:
113
+ text = chunk.decode("utf-8", errors="replace")
114
+ if key.data == "stdout":
115
+ stdout_buf.append(text)
116
+ else:
117
+ stderr_buf.append(text)
118
+
119
+ return "".join(stdout_buf), "".join(stderr_buf)
120
+
121
+ def _ensure_alive(self) -> None:
122
+ """Raise if the session or underlying process is dead."""
123
+ if self._closed:
124
+ raise SessionError(f"Session '{self.name}' is closed.")
125
+ if self._proc.poll() is not None:
126
+ self._closed = True
127
+ raise SessionError(
128
+ f"Session '{self.name}' process exited with code {self._proc.returncode}."
129
+ )
130
+
131
+ def execute(self, command: str, timeout: Optional[float] = None,
132
+ stream_callback: Optional[Callable] = None) -> CommandResult:
133
+ """Run a command. Returns CommandResult.
134
+
135
+ timeout kills the command but keeps the session alive.
136
+ stream_callback gets StreamEvent objects as output arrives.
137
+ """
138
+
139
+ self._ensure_alive()
140
+
141
+ token = self._generate_token()
142
+ start = time.monotonic()
143
+
144
+ # Build the payload
145
+ payload = (
146
+ f"{command}\n"
147
+ f"__bashautom_ec=$?\n"
148
+ f"echo \"{token}:$__bashautom_ec\"\n"
149
+ )
150
+ self._proc.stdin.write(payload.encode("utf-8"))
151
+ self._proc.stdin.flush()
152
+
153
+ # Collect output until we see the token
154
+ stdout_chunks: list[str] = []
155
+ stderr_chunks: list[str] = []
156
+ found_token = False
157
+ exit_code = -1
158
+ timed_out = False
159
+
160
+ while not found_token:
161
+ elapsed = time.monotonic() - start
162
+
163
+ if timeout is not None and elapsed >= timeout and not timed_out:
164
+ timed_out = True
165
+ # Send SIGINT to the process group , kills the child command,
166
+ # but bash survives thanks to `trap : INT`
167
+ try:
168
+ os.killpg(os.getpgid(self._proc.pid), signal.SIGINT)
169
+ except ProcessLookupError:
170
+ pass
171
+ # Don't break , continue reading to find the token
172
+ # (bash will proceed to echo it after the child dies)
173
+ # Extend the deadline so we can capture the token
174
+ timeout = elapsed + 3.0
175
+ continue
176
+
177
+ if timed_out and timeout is not None and elapsed >= timeout:
178
+ # Extended grace period also expired , bail out
179
+ break
180
+
181
+ sel_timeout = None
182
+ if timeout is not None:
183
+ sel_timeout = max(0.01, timeout - elapsed)
184
+
185
+ events = self._sel.select(timeout=min(sel_timeout or 1.0, 1.0))
186
+
187
+ for key, _ in events:
188
+ chunk = key.fileobj.read(65536)
189
+ if not chunk:
190
+ continue
191
+
192
+ text = chunk.decode("utf-8", errors="replace")
193
+ stream_name = key.data
194
+
195
+ if stream_name == "stdout":
196
+ # Check if the token is in this chunk
197
+ if token in text:
198
+ # Split: everything before the token line is real output
199
+ lines = text.split("\n")
200
+ clean_lines = []
201
+ for line in lines:
202
+ if token in line:
203
+ # Parse exit code from "TOKEN:CODE"
204
+ try:
205
+ exit_code = int(line.split(":")[-1].strip())
206
+ except (ValueError, IndexError):
207
+ exit_code = -1
208
+ found_token = True
209
+ else:
210
+ clean_lines.append(line)
211
+ clean_text = "\n".join(clean_lines)
212
+ if clean_text.strip() and stream_callback:
213
+ stream_callback(StreamEvent(
214
+ stream=stream_name,
215
+ data=clean_text,
216
+ timestamp=time.monotonic(),
217
+ ))
218
+ if clean_lines:
219
+ stdout_chunks.append(clean_text)
220
+ else:
221
+ if stream_callback:
222
+ stream_callback(StreamEvent(
223
+ stream=stream_name,
224
+ data=text,
225
+ timestamp=time.monotonic(),
226
+ ))
227
+ stdout_chunks.append(text)
228
+ else:
229
+ if stream_callback:
230
+ stream_callback(StreamEvent(
231
+ stream=stream_name,
232
+ data=text,
233
+ timestamp=time.monotonic(),
234
+ ))
235
+ stderr_chunks.append(text)
236
+
237
+ # Check if process died
238
+ if self._proc.poll() is not None and not found_token:
239
+ self._closed = True
240
+ break
241
+
242
+ duration = time.monotonic() - start
243
+
244
+ stdout_text = "".join(stdout_chunks).strip()
245
+ stderr_text = "".join(stderr_chunks).strip()
246
+
247
+ # Clean up: remove the echo of our payload commands from stdout
248
+ # (bash -i echoes input lines back to stdout)
249
+ for noise in [
250
+ f"__bashautom_ec=$?",
251
+ f'echo "{token}:$__bashautom_ec"',
252
+ ]:
253
+ stdout_text = stdout_text.replace(noise, "")
254
+ stdout_text = stdout_text.strip()
255
+
256
+ return CommandResult(
257
+ command=command,
258
+ stdout=stdout_text,
259
+ stderr=stderr_text,
260
+ exit_code=exit_code,
261
+ duration=duration,
262
+ timed_out=timed_out,
263
+ )
264
+
265
+ def send_signal(self, sig: int = signal.SIGINT) -> None:
266
+ """Send a signal to the process group."""
267
+ self._ensure_alive()
268
+ try:
269
+ os.killpg(os.getpgid(self._proc.pid), sig)
270
+ except ProcessLookupError:
271
+ pass
272
+
273
+ def get_cwd(self) -> str:
274
+ """Current working directory."""
275
+ result = self.execute("pwd")
276
+ return result.stdout.strip()
277
+
278
+ def get_env(self, var: str) -> Optional[str]:
279
+ """Read an env var from the session."""
280
+ result = self.execute(f"echo \"${{{var}}}\"")
281
+ val = result.stdout.strip()
282
+ return val if val else None
283
+
284
+ # TODO: validate var name in set_env to prevent injection
285
+ def set_env(self, var: str, value: str) -> None:
286
+ """Export an env var."""
287
+ self.execute(f"export {var}={value!r}")
288
+
289
+ @property
290
+ def pid(self) -> int:
291
+ """PID of the underlying bash process."""
292
+ return self._proc.pid
293
+
294
+ @property
295
+ def alive(self) -> bool:
296
+ """True if the session process is still running."""
297
+ return not self._closed and self._proc.poll() is None
298
+
299
+ def close(self) -> None:
300
+ """Kill the bash process and cleanup."""
301
+ if self._closed:
302
+ return
303
+ self._closed = True
304
+
305
+ try:
306
+ self._sel.unregister(self._proc.stdout)
307
+ self._sel.unregister(self._proc.stderr)
308
+ except Exception:
309
+ pass
310
+ self._sel.close()
311
+
312
+ try:
313
+ self._proc.stdin.write(b"exit\n")
314
+ self._proc.stdin.flush()
315
+ self._proc.wait(timeout=5)
316
+ except Exception:
317
+ self._proc.kill()
318
+ self._proc.wait()
319
+
320
+ def __enter__(self) -> "Session":
321
+ return self
322
+
323
+ def __exit__(self, *args) -> None:
324
+ self.close()
325
+
326
+ def __del__(self) -> None:
327
+ try:
328
+ self.close()
329
+ except Exception:
330
+ pass
331
+
332
+ def __repr__(self) -> str:
333
+ status = "alive" if self.alive else "closed"
334
+ return f"<Session '{self.name}' [{status}] pid={self._proc.pid}>"
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: bashautom
3
+ Version: 0.1.1
4
+ Summary: Persistent bash sessions for Python
5
+ Author-email: Huskago <huskago@gmail.com>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/huskago/bashautom
8
+ Project-URL: Documentation, https://github.com/huskago/bashautom#readme
9
+ Project-URL: Issues, https://github.com/huskago/bashautom/issues
10
+ Project-URL: Changelog, https://github.com/huskago/bashautom/blob/main/CHANGELOG.md
11
+ Keywords: bash,automation,shell,session,subprocess
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Shells
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # bashautom
23
+
24
+ Persistent bash sessions for Python.
25
+
26
+ Unlike `subprocess.run()` which spawns a new process every time, bashautom keeps a `/bin/bash` process alive so state (env vars, cwd, etc.) persists across commands.
27
+
28
+ ```python
29
+ from bashautom import Session
30
+
31
+ with Session() as s:
32
+ s.execute("cd /opt/myproject")
33
+ s.execute("source .env")
34
+ s.execute("export BUILD_ID=42")
35
+ result = s.execute("make build")
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install bashautom
42
+ ```
43
+
44
+ Python 3.10+, Linux/macOS only.
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from bashautom import Session
50
+
51
+ with Session() as s:
52
+ result = s.execute("echo hello")
53
+ print(result.stdout)
54
+ print(result.exit_code)
55
+ print(result.success)
56
+ ```
57
+
58
+ ### Timeouts
59
+
60
+ Commands can be killed without destroying the session:
61
+
62
+ ```python
63
+ with Session() as s:
64
+ result = s.execute("sleep 60", timeout=3)
65
+ print(result.timed_out)
66
+
67
+ # session still works
68
+ s.execute("echo ok")
69
+ ```
70
+
71
+ ### Streaming
72
+
73
+ ```python
74
+ from bashautom.session import StreamEvent
75
+
76
+ def on_output(event: StreamEvent):
77
+ print(f"[{event.stream}] {event.data.strip()}")
78
+
79
+ with Session() as s:
80
+ s.execute("for i in 1 2 3; do echo $i; sleep 0.5; done", stream_callback=on_output)
81
+ ```
82
+
83
+ ### Multiple sessions
84
+
85
+ ```python
86
+ from bashautom import SessionManager
87
+
88
+ with SessionManager() as mgr:
89
+ build = mgr.create("build", cwd="/opt/project")
90
+ deploy = mgr.create("deploy", cwd="/opt/infra")
91
+
92
+ build.execute("make release")
93
+ deploy.execute("./deploy.sh")
94
+ ```
95
+
96
+ ### Env helpers
97
+
98
+ ```python
99
+ with Session() as s:
100
+ s.set_env("PROJECT", "bashautom")
101
+ print(s.get_env("PROJECT"))
102
+ print(s.get_cwd())
103
+ print(s.pid)
104
+ print(s.alive)
105
+ ```
106
+
107
+ ## API
108
+
109
+ ### Session
110
+
111
+ - `execute(command, timeout=None, stream_callback=None)` - run a command, returns `CommandResult`
112
+ - `send_signal(sig=SIGINT)` - send a signal to the running process
113
+ - `get_cwd()` / `get_env(var)` / `set_env(var, value)` - shell state access
114
+ - `close()` - kill the session
115
+ - `pid`, `alive` - process info
116
+
117
+ ### CommandResult
118
+
119
+ - `command`, `stdout`, `stderr` - what ran and what came back
120
+ - `exit_code`, `success`, `timed_out` - status
121
+ - `duration` - wall time in seconds
122
+
123
+ ### SessionManager
124
+
125
+ - `create(name, ...)` / `get(name)` / `get_or_create(name, ...)` - session lifecycle
126
+ - `close(name)` / `close_all()` - cleanup
127
+ - `names`, `active` - introspection
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,8 @@
1
+ bashautom/__init__.py,sha256=L8iUrtMHLGU9NhmTR2aFGUyar49JK4-7L5_4q3dmbA8,159
2
+ bashautom/manager.py,sha256=2TbeZmc-Uf2M6egwSv9O4vszn_obxOHdBkMj2EhyxQc,2824
3
+ bashautom/session.py,sha256=Hoaul6PsfKOgzXCtVCCnjHIQUhDWFhz0O1vNKnrleFs,11608
4
+ bashautom-0.1.1.dist-info/licenses/LICENSE,sha256=M8xB87PcLMFT_jFhy8CqkGEX9AF25TNrjktNzr9_izo,1064
5
+ bashautom-0.1.1.dist-info/METADATA,sha256=63TADEOSC3v5ne-jfl_C_HNjgeMY82-pJgdHvUn4Y0k,3173
6
+ bashautom-0.1.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
7
+ bashautom-0.1.1.dist-info/top_level.txt,sha256=iie9u75peIF6VFv6xIwA-PTD-2Kev-ZFY3KVN2yMbik,10
8
+ bashautom-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Huskago
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
+ bashautom