bashautom 0.1.1__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 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,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,110 @@
1
+ # bashautom
2
+
3
+ Persistent bash sessions for Python.
4
+
5
+ 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.
6
+
7
+ ```python
8
+ from bashautom import Session
9
+
10
+ with Session() as s:
11
+ s.execute("cd /opt/myproject")
12
+ s.execute("source .env")
13
+ s.execute("export BUILD_ID=42")
14
+ result = s.execute("make build")
15
+ ```
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install bashautom
21
+ ```
22
+
23
+ Python 3.10+, Linux/macOS only.
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from bashautom import Session
29
+
30
+ with Session() as s:
31
+ result = s.execute("echo hello")
32
+ print(result.stdout)
33
+ print(result.exit_code)
34
+ print(result.success)
35
+ ```
36
+
37
+ ### Timeouts
38
+
39
+ Commands can be killed without destroying the session:
40
+
41
+ ```python
42
+ with Session() as s:
43
+ result = s.execute("sleep 60", timeout=3)
44
+ print(result.timed_out)
45
+
46
+ # session still works
47
+ s.execute("echo ok")
48
+ ```
49
+
50
+ ### Streaming
51
+
52
+ ```python
53
+ from bashautom.session import StreamEvent
54
+
55
+ def on_output(event: StreamEvent):
56
+ print(f"[{event.stream}] {event.data.strip()}")
57
+
58
+ with Session() as s:
59
+ s.execute("for i in 1 2 3; do echo $i; sleep 0.5; done", stream_callback=on_output)
60
+ ```
61
+
62
+ ### Multiple sessions
63
+
64
+ ```python
65
+ from bashautom import SessionManager
66
+
67
+ with SessionManager() as mgr:
68
+ build = mgr.create("build", cwd="/opt/project")
69
+ deploy = mgr.create("deploy", cwd="/opt/infra")
70
+
71
+ build.execute("make release")
72
+ deploy.execute("./deploy.sh")
73
+ ```
74
+
75
+ ### Env helpers
76
+
77
+ ```python
78
+ with Session() as s:
79
+ s.set_env("PROJECT", "bashautom")
80
+ print(s.get_env("PROJECT"))
81
+ print(s.get_cwd())
82
+ print(s.pid)
83
+ print(s.alive)
84
+ ```
85
+
86
+ ## API
87
+
88
+ ### Session
89
+
90
+ - `execute(command, timeout=None, stream_callback=None)` - run a command, returns `CommandResult`
91
+ - `send_signal(sig=SIGINT)` - send a signal to the running process
92
+ - `get_cwd()` / `get_env(var)` / `set_env(var, value)` - shell state access
93
+ - `close()` - kill the session
94
+ - `pid`, `alive` - process info
95
+
96
+ ### CommandResult
97
+
98
+ - `command`, `stdout`, `stderr` - what ran and what came back
99
+ - `exit_code`, `success`, `timed_out` - status
100
+ - `duration` - wall time in seconds
101
+
102
+ ### SessionManager
103
+
104
+ - `create(name, ...)` / `get(name)` / `get_or_create(name, ...)` - session lifecycle
105
+ - `close(name)` / `close_all()` - cleanup
106
+ - `names`, `active` - introspection
107
+
108
+ ## License
109
+
110
+ MIT
@@ -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"
@@ -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}>"
@@ -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,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ bashautom/__init__.py
5
+ bashautom/manager.py
6
+ bashautom/session.py
7
+ bashautom.egg-info/PKG-INFO
8
+ bashautom.egg-info/SOURCES.txt
9
+ bashautom.egg-info/dependency_links.txt
10
+ bashautom.egg-info/top_level.txt
11
+ tests/test_session.py
@@ -0,0 +1 @@
1
+ bashautom
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bashautom"
7
+ version = "0.1.1"
8
+ description = "Persistent bash sessions for Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Huskago", email = "huskago@gmail.com" }]
13
+ keywords = ["bash", "automation", "shell", "session", "subprocess"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: System :: Shells",
19
+ "License :: OSI Approved :: MIT License",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/huskago/bashautom"
24
+ Documentation = "https://github.com/huskago/bashautom#readme"
25
+ Issues = "https://github.com/huskago/bashautom/issues"
26
+ Changelog = "https://github.com/huskago/bashautom/blob/main/CHANGELOG.md"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,244 @@
1
+ import time
2
+ import signal
3
+ import pytest
4
+ from bashautom import Session, SessionManager
5
+ from bashautom.session import StreamEvent
6
+
7
+
8
+ class TestSession:
9
+ def test_simple_command(self):
10
+ with Session() as s:
11
+ r = s.execute("echo hello")
12
+ assert r.stdout == "hello"
13
+ assert r.exit_code == 0
14
+ assert r.success
15
+
16
+ def test_exit_code(self):
17
+ with Session() as s:
18
+ r = s.execute("(exit 42)")
19
+ assert r.exit_code == 42
20
+ assert not r.success
21
+
22
+ def test_failed_command(self):
23
+ with Session() as s:
24
+ r = s.execute("ls /nonexistent_path_12345")
25
+ assert not r.success
26
+ assert r.exit_code != 0
27
+ assert r.stderr
28
+
29
+ def test_stderr(self):
30
+ with Session() as s:
31
+ r = s.execute("echo err >&2")
32
+ assert "err" in r.stderr
33
+
34
+ def test_state_persists(self):
35
+ with Session() as s:
36
+ s.execute("export FOO=bar")
37
+ r = s.execute("echo $FOO")
38
+ assert r.stdout == "bar"
39
+
40
+ def test_cwd_persists(self):
41
+ with Session() as s:
42
+ s.execute("cd /tmp")
43
+ r = s.execute("pwd")
44
+ assert r.stdout == "/tmp"
45
+
46
+ def test_multiline_output(self):
47
+ with Session() as s:
48
+ r = s.execute("echo -e 'a\\nb\\nc'")
49
+ lines = r.stdout.strip().splitlines()
50
+ assert len(lines) == 3
51
+
52
+ def test_empty_output(self):
53
+ with Session() as s:
54
+ r = s.execute("true")
55
+ assert r.stdout == ""
56
+ assert r.success
57
+
58
+ def test_duration_tracked(self):
59
+ with Session() as s:
60
+ r = s.execute("sleep 0.2")
61
+ assert r.duration >= 0.15
62
+
63
+ def test_special_chars(self):
64
+ with Session() as s:
65
+ r = s.execute("echo 'hello \"world\" $HOME'")
66
+ assert "hello" in r.stdout
67
+ assert "$HOME" not in r.stdout or "hello" in r.stdout
68
+
69
+
70
+ class TestTimeout:
71
+ def test_timeout_kills_command(self):
72
+ with Session() as s:
73
+ r = s.execute("sleep 30", timeout=1)
74
+ assert r.timed_out
75
+ assert not r.success
76
+ assert r.duration < 5
77
+
78
+ def test_session_survives_timeout(self):
79
+ with Session() as s:
80
+ s.execute("sleep 30", timeout=1)
81
+ r = s.execute("echo alive")
82
+ assert r.stdout == "alive"
83
+ assert r.success
84
+
85
+ def test_no_timeout_if_fast(self):
86
+ with Session() as s:
87
+ r = s.execute("echo fast", timeout=10)
88
+ assert not r.timed_out
89
+ assert r.success
90
+
91
+
92
+ class TestStreaming:
93
+ def test_stream_callback(self):
94
+ chunks = []
95
+
96
+ def cb(event: StreamEvent):
97
+ chunks.append(event)
98
+
99
+ with Session() as s:
100
+ s.execute("echo one; echo two; echo three", stream_callback=cb)
101
+
102
+ stdout_data = "".join(e.data for e in chunks if e.stream == "stdout")
103
+ assert "one" in stdout_data
104
+ assert "two" in stdout_data
105
+ assert "three" in stdout_data
106
+
107
+ def test_stream_event_fields(self):
108
+ events = []
109
+
110
+ def cb(event: StreamEvent):
111
+ events.append(event)
112
+
113
+ with Session() as s:
114
+ s.execute("echo test", stream_callback=cb)
115
+
116
+ stdout_events = [e for e in events if e.stream == "stdout"]
117
+ assert len(stdout_events) > 0
118
+ assert stdout_events[0].timestamp > 0
119
+
120
+
121
+ class TestEnvHelpers:
122
+ def test_set_get_env(self):
123
+ with Session() as s:
124
+ s.set_env("TEST_VAR", "hello123")
125
+ assert s.get_env("TEST_VAR") == "hello123"
126
+
127
+ def test_get_env_unset(self):
128
+ with Session() as s:
129
+ val = s.get_env("DOESNT_EXIST_SURELY_12345")
130
+ assert val is None
131
+
132
+ def test_get_cwd(self):
133
+ with Session() as s:
134
+ s.execute("cd /tmp")
135
+ assert s.get_cwd() == "/tmp"
136
+
137
+
138
+ class TestSessionLifecycle:
139
+ def test_pid(self):
140
+ with Session() as s:
141
+ assert isinstance(s.pid, int)
142
+ assert s.pid > 0
143
+
144
+ def test_alive(self):
145
+ s = Session()
146
+ assert s.alive
147
+ s.close()
148
+ assert not s.alive
149
+
150
+ def test_double_close(self):
151
+ s = Session()
152
+ s.close()
153
+ s.close() # should not raise
154
+
155
+ def test_context_manager(self):
156
+ with Session() as s:
157
+ assert s.alive
158
+ assert not s.alive
159
+
160
+ def test_name_default(self):
161
+ with Session() as s:
162
+ assert s.name.startswith("session-")
163
+
164
+ def test_name_custom(self):
165
+ with Session(name="mytest") as s:
166
+ assert s.name == "mytest"
167
+
168
+ def test_execute_after_close_raises(self):
169
+ s = Session()
170
+ s.close()
171
+ with pytest.raises(Exception):
172
+ s.execute("echo nope")
173
+
174
+
175
+ class TestSessionManager:
176
+ def test_create_and_get(self):
177
+ with SessionManager() as mgr:
178
+ mgr.create("a")
179
+ s = mgr.get("a")
180
+ assert s.alive
181
+
182
+ def test_create_duplicate_raises(self):
183
+ with SessionManager() as mgr:
184
+ mgr.create("x")
185
+ with pytest.raises(ValueError):
186
+ mgr.create("x")
187
+
188
+ def test_get_missing_raises(self):
189
+ with SessionManager() as mgr:
190
+ with pytest.raises(KeyError):
191
+ mgr.get("nope")
192
+
193
+ def test_get_or_create(self):
194
+ with SessionManager() as mgr:
195
+ s1 = mgr.get_or_create("w")
196
+ s2 = mgr.get_or_create("w")
197
+ assert s1 is s2
198
+
199
+ def test_close_one(self):
200
+ with SessionManager() as mgr:
201
+ mgr.create("a")
202
+ mgr.create("b")
203
+ mgr.close("a")
204
+ assert "a" not in mgr
205
+ assert "b" in mgr
206
+
207
+ def test_names(self):
208
+ with SessionManager() as mgr:
209
+ mgr.create("x")
210
+ mgr.create("y")
211
+ assert set(mgr.names) == {"x", "y"}
212
+
213
+ def test_active(self):
214
+ with SessionManager() as mgr:
215
+ mgr.create("a")
216
+ mgr.create("b")
217
+ assert len(mgr.active) == 2
218
+
219
+ def test_close_all(self):
220
+ with SessionManager() as mgr:
221
+ mgr.create("a")
222
+ mgr.create("b")
223
+ mgr.close_all()
224
+ assert len(mgr) == 0
225
+
226
+ def test_contains(self):
227
+ with SessionManager() as mgr:
228
+ mgr.create("test")
229
+ assert "test" in mgr
230
+ assert "nope" not in mgr
231
+
232
+ def test_getitem(self):
233
+ with SessionManager() as mgr:
234
+ mgr.create("s")
235
+ assert mgr["s"].alive
236
+
237
+ def test_isolation(self):
238
+ with SessionManager() as mgr:
239
+ a = mgr.create("a")
240
+ b = mgr.create("b")
241
+ a.execute("export X=fromA")
242
+ b.execute("export X=fromB")
243
+ assert a.execute("echo $X").stdout == "fromA"
244
+ assert b.execute("echo $X").stdout == "fromB"