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.
- bashautom-0.1.1/LICENSE +21 -0
- bashautom-0.1.1/PKG-INFO +131 -0
- bashautom-0.1.1/README.md +110 -0
- bashautom-0.1.1/bashautom/__init__.py +5 -0
- bashautom-0.1.1/bashautom/manager.py +90 -0
- bashautom-0.1.1/bashautom/session.py +334 -0
- bashautom-0.1.1/bashautom.egg-info/PKG-INFO +131 -0
- bashautom-0.1.1/bashautom.egg-info/SOURCES.txt +11 -0
- bashautom-0.1.1/bashautom.egg-info/dependency_links.txt +1 -0
- bashautom-0.1.1/bashautom.egg-info/top_level.txt +1 -0
- bashautom-0.1.1/pyproject.toml +26 -0
- bashautom-0.1.1/setup.cfg +4 -0
- bashautom-0.1.1/tests/test_session.py +244 -0
bashautom-0.1.1/LICENSE
ADDED
|
@@ -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.
|
bashautom-0.1.1/PKG-INFO
ADDED
|
@@ -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,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
|
+
|
|
@@ -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,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"
|