ptylink 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ptylink-0.1.0/.github/dependabot.yml +10 -0
- ptylink-0.1.0/.github/workflows/ci.yml +49 -0
- ptylink-0.1.0/.github/workflows/publish.yml +19 -0
- ptylink-0.1.0/.gitignore +16 -0
- ptylink-0.1.0/CHANGELOG.md +33 -0
- ptylink-0.1.0/PKG-INFO +291 -0
- ptylink-0.1.0/PLAN.md +266 -0
- ptylink-0.1.0/README.md +260 -0
- ptylink-0.1.0/benchmarks/RESULTS.md +14 -0
- ptylink-0.1.0/benchmarks/bench_ptylink.py +107 -0
- ptylink-0.1.0/pyproject.toml +79 -0
- ptylink-0.1.0/pyrightconfig.json +6 -0
- ptylink-0.1.0/src/ptylink/__init__.py +50 -0
- ptylink-0.1.0/src/ptylink/_async.py +402 -0
- ptylink-0.1.0/src/ptylink/_compat.py +46 -0
- ptylink-0.1.0/src/ptylink/_errors.py +48 -0
- ptylink-0.1.0/src/ptylink/_expect.py +177 -0
- ptylink-0.1.0/src/ptylink/_interact.py +64 -0
- ptylink-0.1.0/src/ptylink/_popen.py +288 -0
- ptylink-0.1.0/src/ptylink/_pty.py +235 -0
- ptylink-0.1.0/src/ptylink/_run.py +86 -0
- ptylink-0.1.0/src/ptylink/_screen.py +30 -0
- ptylink-0.1.0/src/ptylink/_spawn.py +363 -0
- ptylink-0.1.0/src/ptylink/_ssh.py +136 -0
- ptylink-0.1.0/src/ptylink/_types.py +76 -0
- ptylink-0.1.0/src/ptylink/compat.py +7 -0
- ptylink-0.1.0/src/ptylink/py.typed +0 -0
- ptylink-0.1.0/tests/test_async.py +131 -0
- ptylink-0.1.0/tests/test_compat.py +179 -0
- ptylink-0.1.0/tests/test_errors.py +116 -0
- ptylink-0.1.0/tests/test_expect.py +280 -0
- ptylink-0.1.0/tests/test_popen.py +94 -0
- ptylink-0.1.0/tests/test_pty.py +157 -0
- ptylink-0.1.0/tests/test_run.py +47 -0
- ptylink-0.1.0/tests/test_screen.py +48 -0
- ptylink-0.1.0/tests/test_spawn.py +257 -0
- ptylink-0.1.0/tests/test_spawn_advanced.py +64 -0
- ptylink-0.1.0/tests/test_ssh.py +71 -0
- ptylink-0.1.0/uv.lock +400 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
os: [ubuntu-latest, macos-latest]
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
21
|
+
run: uv python install ${{ matrix.python-version }}
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: uv sync --dev
|
|
24
|
+
- name: Run tests
|
|
25
|
+
run: uv run python -m pytest tests/ -q
|
|
26
|
+
|
|
27
|
+
typecheck:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
- uses: astral-sh/setup-uv@v5
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: uv sync --dev
|
|
34
|
+
- name: mypy --strict
|
|
35
|
+
run: uv run mypy --strict src/ptylink/
|
|
36
|
+
- name: pyright
|
|
37
|
+
run: uv run pyright src/ptylink/
|
|
38
|
+
|
|
39
|
+
lint:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
- uses: astral-sh/setup-uv@v5
|
|
44
|
+
- name: Install dependencies
|
|
45
|
+
run: uv sync --dev
|
|
46
|
+
- name: Ruff check
|
|
47
|
+
run: uv run ruff check src/ tests/
|
|
48
|
+
- name: Ruff format check
|
|
49
|
+
run: uv run ruff format --check src/ tests/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v5
|
|
16
|
+
- name: Build distribution
|
|
17
|
+
run: uv build
|
|
18
|
+
- name: Publish to PyPI
|
|
19
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
ptylink-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
> **Note:** This package was previously published as **tether**. It was renamed to **ptylink** after v0.1.0.
|
|
4
|
+
> If you were using `tether`, update your dependency to `ptylink` and replace any `TetherError` references with `PtylinkError`.
|
|
5
|
+
|
|
6
|
+
## v0.1.0 (2026-03-13)
|
|
7
|
+
|
|
8
|
+
Initial release as `tether` — modern drop-in replacement for pexpect. Renamed to `ptylink` post-release.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
- `Spawn` class with PTY-based process interaction
|
|
13
|
+
- `AsyncSpawn`: native `async/await` (not `async_=True` hack)
|
|
14
|
+
- `PopenSpawn`: pipe-based spawn for environments without PTY
|
|
15
|
+
- `SSHSession`: SSH login/command helper built on Spawn
|
|
16
|
+
- `run()` high-level function with events support
|
|
17
|
+
- Pattern matching: regex, exact string, EOF, TIMEOUT sentinels
|
|
18
|
+
- `strip_ansi()` and `has_ansi()` ANSI escape handling
|
|
19
|
+
- `interact()` standalone function with input/output filters
|
|
20
|
+
- pexpect compatibility shim (`ptylink.compat`)
|
|
21
|
+
- Zero dependencies
|
|
22
|
+
- Full type annotations — `mypy --strict` + `pyright strict` clean
|
|
23
|
+
- Python 3.10+
|
|
24
|
+
|
|
25
|
+
### Bug fixes vs pexpect
|
|
26
|
+
|
|
27
|
+
- `before` attribute no longer contains leaked `sendline()` echo (#821)
|
|
28
|
+
- No Python 3.12+ `ResourceWarning` on fork (#817)
|
|
29
|
+
- No deprecated `asyncio.coroutine` usage (#677)
|
|
30
|
+
|
|
31
|
+
### Performance
|
|
32
|
+
|
|
33
|
+
- 3.6–95× faster than pexpect across all benchmarked operations
|
ptylink-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ptylink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Modern process interaction library — expect-style automation with PTY support
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentine/ptylink
|
|
6
|
+
Project-URL: Repository, https://github.com/agentine/ptylink
|
|
7
|
+
Project-URL: Issues, https://github.com/agentine/ptylink/issues
|
|
8
|
+
Author: Agentine
|
|
9
|
+
License-Expression: ISC
|
|
10
|
+
Keywords: automation,expect,pexpect,process,pty,terminal
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: ISC License (ISCL)
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
26
|
+
Requires-Dist: pexpect>=4.9.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# ptylink
|
|
33
|
+
|
|
34
|
+
[](https://github.com/agentine/ptylink/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/ptylink/)
|
|
36
|
+
[](https://pypi.org/project/ptylink/)
|
|
37
|
+
|
|
38
|
+
Modern process interaction library for Python — expect-style automation with PTY support.
|
|
39
|
+
|
|
40
|
+
Drop-in replacement for [pexpect](https://github.com/pexpect/pexpect) with full type annotations, native async/await, and zero dependencies.
|
|
41
|
+
|
|
42
|
+
## Why ptylink?
|
|
43
|
+
|
|
44
|
+
| | pexpect | ptylink |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| **Type annotations** | No | Full (`mypy --strict` + `pyright strict`) |
|
|
47
|
+
| **Async support** | Deprecated `@asyncio.coroutine` | Native `async/await` via `AsyncSpawn` |
|
|
48
|
+
| **Python 3.12+ fork warning** | Yes (#817) | Fixed |
|
|
49
|
+
| **`before` leaks sendline echo** | Yes (#821) | Fixed |
|
|
50
|
+
| **Dependencies** | `ptyprocess` | Zero |
|
|
51
|
+
| **Performance** | Baseline | 3.6–95× faster |
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install ptylink
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Requires Python 3.10+.
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import ptylink
|
|
65
|
+
|
|
66
|
+
with ptylink.spawn("python3") as child:
|
|
67
|
+
child.expect(">>> ")
|
|
68
|
+
child.sendline("print(42)")
|
|
69
|
+
child.expect("42")
|
|
70
|
+
print(child.before) # text before the match
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Context Manager
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from ptylink import Spawn
|
|
77
|
+
|
|
78
|
+
with Spawn("ssh user@host") as child:
|
|
79
|
+
child.expect("password:")
|
|
80
|
+
child.sendline("secret")
|
|
81
|
+
child.expect(r"\$ ")
|
|
82
|
+
child.sendline("ls")
|
|
83
|
+
child.expect(r"\$ ")
|
|
84
|
+
print(child.before)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Async Usage
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import asyncio
|
|
91
|
+
from ptylink import AsyncSpawn
|
|
92
|
+
|
|
93
|
+
async def main():
|
|
94
|
+
async with AsyncSpawn("python3") as child:
|
|
95
|
+
await child.expect(">>> ")
|
|
96
|
+
await child.sendline("1 + 1")
|
|
97
|
+
await child.expect("2")
|
|
98
|
+
|
|
99
|
+
asyncio.run(main())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Pipe-Based (No PTY)
|
|
103
|
+
|
|
104
|
+
For environments without PTY support (e.g. Windows):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from ptylink import PopenSpawn
|
|
108
|
+
|
|
109
|
+
with PopenSpawn("echo hello") as child:
|
|
110
|
+
child.expect("hello")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## High-Level `run()`
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from ptylink import run
|
|
117
|
+
|
|
118
|
+
# Simple command
|
|
119
|
+
output = run("ls -la")
|
|
120
|
+
|
|
121
|
+
# With exit status
|
|
122
|
+
output, status = run("make test", withexitstatus=True)
|
|
123
|
+
|
|
124
|
+
# Interactive with events
|
|
125
|
+
output = run(
|
|
126
|
+
"sudo apt install foo",
|
|
127
|
+
events={"password:": "secret\n"},
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## SSH Sessions
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from ptylink import SSHSession
|
|
135
|
+
|
|
136
|
+
with SSHSession("server.example.com", username="admin") as ssh:
|
|
137
|
+
ssh.login(password="secret")
|
|
138
|
+
output = ssh.run("uname -a")
|
|
139
|
+
print(output)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Pattern Matching
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import re
|
|
146
|
+
from ptylink import Spawn, EOF_TYPE, TIMEOUT_TYPE
|
|
147
|
+
|
|
148
|
+
with Spawn("some_program") as child:
|
|
149
|
+
# String patterns (auto-escaped)
|
|
150
|
+
child.expect("login:")
|
|
151
|
+
|
|
152
|
+
# Regex patterns
|
|
153
|
+
child.expect(re.compile(r"[\$#] "))
|
|
154
|
+
|
|
155
|
+
# Multiple patterns — returns index of match
|
|
156
|
+
idx = child.expect_list(["error", "success", EOF_TYPE])
|
|
157
|
+
if idx == 0:
|
|
158
|
+
print("Error:", child.after)
|
|
159
|
+
elif idx == 1:
|
|
160
|
+
print("Success!")
|
|
161
|
+
elif idx == 2:
|
|
162
|
+
print("Process ended")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## API Reference
|
|
166
|
+
|
|
167
|
+
### Classes and Functions
|
|
168
|
+
|
|
169
|
+
- **`Spawn(command, *, timeout=30, encoding='utf-8', env=None, cwd=None)`** — PTY-based process interaction
|
|
170
|
+
- **`spawn(command, *, timeout=30, encoding='utf-8')`** — Factory function; returns a `Spawn` instance
|
|
171
|
+
- **`AsyncSpawn(command, ...)`** — Async version of Spawn
|
|
172
|
+
- **`PopenSpawn(command, ...)`** — Pipe-based (no PTY) process interaction
|
|
173
|
+
- **`SSHSession(server, *, username=None, port=22, password=None)`** — SSH session helper
|
|
174
|
+
|
|
175
|
+
### Spawn Methods
|
|
176
|
+
|
|
177
|
+
| Method | Description |
|
|
178
|
+
|--------|-------------|
|
|
179
|
+
| `expect(pattern, *, timeout=-1)` | Wait for pattern in output |
|
|
180
|
+
| `expect_exact(pattern, *, timeout=-1)` | Wait for exact string |
|
|
181
|
+
| `expect_list(patterns, *, timeout=-1)` | Wait for any pattern, return index |
|
|
182
|
+
| `send(s)` | Send string to process |
|
|
183
|
+
| `sendline(s='')` | Send string + newline |
|
|
184
|
+
| `sendcontrol(char)` | Send control character (e.g. `'c'` for Ctrl-C) |
|
|
185
|
+
| `sendeof()` | Send EOF (Ctrl-D) |
|
|
186
|
+
| `read(size=-1)` | Read from process output |
|
|
187
|
+
| `readline()` | Read a single line |
|
|
188
|
+
| `isalive()` | Check if process is running |
|
|
189
|
+
| `wait()` | Wait for exit, return exit code |
|
|
190
|
+
| `close(force=True)` | Close process and PTY |
|
|
191
|
+
| `setwinsize(rows, cols)` | Set terminal dimensions |
|
|
192
|
+
| `interact()` | Interactive passthrough mode |
|
|
193
|
+
|
|
194
|
+
### Attributes
|
|
195
|
+
|
|
196
|
+
- `before` — Text before the last match
|
|
197
|
+
- `after` — Text of the last match
|
|
198
|
+
- `match` — Match object or string from last expect
|
|
199
|
+
|
|
200
|
+
### Exceptions
|
|
201
|
+
|
|
202
|
+
- `PtylinkError` — Base exception
|
|
203
|
+
- `Timeout` — Expect timed out
|
|
204
|
+
- `EOF` — Process closed output
|
|
205
|
+
- `ExitStatus` — Process exited with non-zero status
|
|
206
|
+
|
|
207
|
+
### Sentinels
|
|
208
|
+
|
|
209
|
+
- `EOF_TYPE` — Use in pattern lists to match EOF without raising
|
|
210
|
+
- `TIMEOUT_TYPE` — Use in pattern lists to match timeout without raising
|
|
211
|
+
|
|
212
|
+
## ANSI Utilities
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from ptylink import strip_ansi, has_ansi
|
|
216
|
+
|
|
217
|
+
clean = strip_ansi("\x1b[31mred text\x1b[0m") # "red text"
|
|
218
|
+
has_ansi("\x1b[1mbold\x1b[0m") # True
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Migrating from tether
|
|
222
|
+
|
|
223
|
+
This package was previously named **tether**. To upgrade:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
pip uninstall tether
|
|
227
|
+
pip install ptylink
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Then update your imports and any exception references:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
# Before
|
|
234
|
+
import tether
|
|
235
|
+
from tether import TetherError
|
|
236
|
+
|
|
237
|
+
# After
|
|
238
|
+
import ptylink
|
|
239
|
+
from ptylink import PtylinkError
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
All other APIs are identical.
|
|
243
|
+
|
|
244
|
+
## pexpect Migration Guide
|
|
245
|
+
|
|
246
|
+
### Zero-Change Migration
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# Before
|
|
250
|
+
import pexpect
|
|
251
|
+
|
|
252
|
+
# After — just change the import
|
|
253
|
+
import ptylink.compat as pexpect
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
All pexpect names are available: `spawn`, `run`, `EOF`, `TIMEOUT`, `pxssh`.
|
|
257
|
+
|
|
258
|
+
### Manual Migration
|
|
259
|
+
|
|
260
|
+
| pexpect | ptylink |
|
|
261
|
+
|---------|--------|
|
|
262
|
+
| `pexpect.spawn(cmd)` | `ptylink.Spawn(cmd)` |
|
|
263
|
+
| `pexpect.run(cmd)` | `ptylink.run(cmd)` |
|
|
264
|
+
| `pexpect.EOF` | `ptylink.EOF_TYPE` |
|
|
265
|
+
| `pexpect.TIMEOUT` | `ptylink.TIMEOUT_TYPE` |
|
|
266
|
+
| `pexpect.pxssh.pxssh()` | `ptylink.SSHSession()` |
|
|
267
|
+
| `pexpect.spawn(cmd, async_=True)` | `ptylink.AsyncSpawn(cmd)` |
|
|
268
|
+
|
|
269
|
+
### Breaking Changes
|
|
270
|
+
|
|
271
|
+
- `EOF` and `TIMEOUT` are sentinel types, not exception classes. Use `EOF_TYPE` and `TIMEOUT_TYPE` in pattern lists.
|
|
272
|
+
- `async_=True` parameter is removed. Use `AsyncSpawn` instead.
|
|
273
|
+
- `before` attribute no longer contains leaked `sendline()` echo text.
|
|
274
|
+
|
|
275
|
+
## Fixes from pexpect
|
|
276
|
+
|
|
277
|
+
- **#821** — `before` attribute no longer contains echoed `sendline()` input
|
|
278
|
+
- **#817** — No `ResourceWarning` from `os.fork()` on Python 3.12+
|
|
279
|
+
- **#677** — No deprecated `@asyncio.coroutine` usage; native `async def` throughout
|
|
280
|
+
|
|
281
|
+
## Benchmarks
|
|
282
|
+
|
|
283
|
+
| Operation | ptylink (us/op) | pexpect (us/op) | Speedup |
|
|
284
|
+
|-----------|---------------:|----------------:|--------:|
|
|
285
|
+
| spawn+expect (echo) | 3,002 | 285,821 | 95x |
|
|
286
|
+
| spawn+expect (python) | 84,312 | 307,303 | 3.6x |
|
|
287
|
+
| run (echo) | 3,352 | 163,946 | 49x |
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
ISC
|
ptylink-0.1.0/PLAN.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# ptylink — Implementation Plan
|
|
2
|
+
|
|
3
|
+
**Target:** Replace `pexpect` (pexpect/pexpect)
|
|
4
|
+
**Package name:** `ptylink` (verified available on PyPI 2026-03-13)
|
|
5
|
+
**License:** ISC
|
|
6
|
+
**Python:** >= 3.10
|
|
7
|
+
**Dependencies:** Zero (pure Python, PTY handling built-in)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Problem Statement
|
|
12
|
+
|
|
13
|
+
`pexpect` is the dominant Python library for controlling interactive programs in a pseudo-terminal with **149M downloads/month** (PyPI #152). It has:
|
|
14
|
+
|
|
15
|
+
- **Two primary maintainers** (takluyver: 432 commits, jquast: 320 commits) — high bus factor risk
|
|
16
|
+
- **Stale releases** — last release v4.9 on November 25, 2023 (2+ years ago); previous release v4.8 was January 2020
|
|
17
|
+
- **165 open issues** including deprecated asyncio.coroutine usage (#677), Python 3.12 fork warnings (#817), ANSI leaks on macOS 3.14 (#824), bare except clauses (#826)
|
|
18
|
+
- **Stale pull requests** — PRs from 2024-2026 unmerged (e.g., #826 basic cleanup, #822 dependency bump)
|
|
19
|
+
- **No type annotations** — no py.typed, no mypy/pyright support
|
|
20
|
+
- **Weak async support** — only `async_=True` parameter hack, uses deprecated `@asyncio.coroutine`
|
|
21
|
+
- **Separate ptyprocess dependency** — also stale (last release v0.7.0, December 2020)
|
|
22
|
+
- **Poor Windows support** — popen_spawn fallback only, no ConPTY
|
|
23
|
+
- **No funding** — not on Tidelift, no GitHub Sponsors, no corporate backing
|
|
24
|
+
- **No well-known replacement** — wexpect is Windows-only; no modern async-first expect library exists
|
|
25
|
+
|
|
26
|
+
## Scope
|
|
27
|
+
|
|
28
|
+
Modern process interaction library for controlling interactive CLI programs:
|
|
29
|
+
|
|
30
|
+
1. **Process spawning** — PTY allocation (Unix), ConPTY (Windows 10+), Popen fallback
|
|
31
|
+
2. **Pattern matching** — expect patterns on process output (regex, literal, EOF, TIMEOUT)
|
|
32
|
+
3. **Input sending** — send, sendline, sendcontrol, sendeof, sendintr
|
|
33
|
+
4. **Timeout handling** — per-operation and default timeouts
|
|
34
|
+
5. **Session state** — before/after/match attributes for matched content
|
|
35
|
+
6. **Interactive mode** — passthrough for manual interaction
|
|
36
|
+
7. **High-level API** — run() for simple command execution with expect
|
|
37
|
+
8. **SSH helper** — SSH session management (login, command execution)
|
|
38
|
+
9. **Async support** — native async/await for all operations
|
|
39
|
+
10. **pexpect compatibility** — drop-in shim for existing code
|
|
40
|
+
|
|
41
|
+
## Architecture Overview
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
src/ptylink/
|
|
45
|
+
├── __init__.py # Public API exports
|
|
46
|
+
├── py.typed # PEP 561 marker
|
|
47
|
+
├── _types.py # Type aliases, protocols, sentinel types
|
|
48
|
+
├── _errors.py # Exception hierarchy (Timeout, EOF, ExitStatus)
|
|
49
|
+
├── _pty.py # PTY allocation and management (replaces ptyprocess)
|
|
50
|
+
├── _spawn.py # Spawn class — core process interaction
|
|
51
|
+
├── _expect.py # Pattern matching engine (compile, search, match)
|
|
52
|
+
├── _interact.py # Interactive passthrough mode
|
|
53
|
+
├── _run.py # High-level run() function
|
|
54
|
+
├── _popen.py # PopenSpawn — non-PTY spawn (Windows, pipes)
|
|
55
|
+
├── _ssh.py # SSH session helper (login, prompts, commands)
|
|
56
|
+
├── _screen.py # ANSI escape sequence handling
|
|
57
|
+
├── _async.py # AsyncSpawn — native async/await spawn
|
|
58
|
+
└── _compat.py # pexpect drop-in compatibility shim
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Key Design Decisions
|
|
62
|
+
|
|
63
|
+
- **Private modules, public API via `__init__.py`** — all internal modules prefixed with `_`, public surface is `ptylink.spawn()`, `ptylink.run()`, `ptylink.Spawn`, etc.
|
|
64
|
+
- **Async-first internals** — core I/O uses asyncio; sync API wraps async with `asyncio.run()` / event loop
|
|
65
|
+
- **Built-in PTY** — PTY handling built directly into the library (no ptyprocess dependency)
|
|
66
|
+
- **Context manager support** — `with ptylink.spawn("cmd") as child:` for automatic cleanup
|
|
67
|
+
- **Compiled patterns** — expect patterns compiled once and reused
|
|
68
|
+
- **Sentinel types** — `EOF` and `TIMEOUT` are proper singleton types, not magic integers
|
|
69
|
+
- **Modern Python** — 3.10+ required; uses match statements, `|` union types, slots
|
|
70
|
+
|
|
71
|
+
## Major Components
|
|
72
|
+
|
|
73
|
+
### 1. Exception Hierarchy (`_errors.py`)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
class PtylinkError(Exception): ... # Base
|
|
77
|
+
class Timeout(PtylinkError): ... # Expect timeout
|
|
78
|
+
class EOF(PtylinkError): ... # Process closed output
|
|
79
|
+
class ExitStatus(PtylinkError): # Process exited with error
|
|
80
|
+
status: int
|
|
81
|
+
signal: int | None
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Fix over pexpect:** Clear exception types with proper attributes. pexpect EOF/TIMEOUT are exception classes AND sentinel values — ptylink separates these concerns.
|
|
85
|
+
|
|
86
|
+
### 2. PTY Management (`_pty.py`)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
class PtyProcess:
|
|
90
|
+
pid: int
|
|
91
|
+
fd: int
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def spawn(cls, argv: list[str], ...) -> PtyProcess: ...
|
|
95
|
+
def read(self, size: int = 1024) -> bytes: ...
|
|
96
|
+
def write(self, data: bytes) -> int: ...
|
|
97
|
+
def setwinsize(self, rows: int, cols: int) -> None: ...
|
|
98
|
+
def waitpid(self) -> tuple[int, int]: ...
|
|
99
|
+
def terminate(self, force: bool = False) -> bool: ...
|
|
100
|
+
def isalive(self) -> bool: ...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Replaces ptyprocess with built-in PTY handling. Uses `pty.openpty()` + `os.fork()` on Unix with proper signal handling.
|
|
104
|
+
|
|
105
|
+
**Fix over pexpect/ptyprocess:** Handles Python 3.12+ fork-with-threads warning (#817). Uses `os.login_tty()` on Python 3.13+.
|
|
106
|
+
|
|
107
|
+
### 3. Spawn Class (`_spawn.py`)
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
class Spawn:
|
|
111
|
+
before: str # Text before last match
|
|
112
|
+
after: str # Text that matched
|
|
113
|
+
match: re.Match | str | None
|
|
114
|
+
|
|
115
|
+
def __init__(self, command: str, *, timeout: float = 30, encoding: str = "utf-8", ...) -> None: ...
|
|
116
|
+
def __enter__(self) -> Spawn: ...
|
|
117
|
+
def __exit__(self, ...) -> None: ...
|
|
118
|
+
def expect(self, pattern: Pattern, *, timeout: float = -1) -> int: ...
|
|
119
|
+
def expect_exact(self, pattern: str | list[str], ...) -> int: ...
|
|
120
|
+
def expect_list(self, patterns: list[Pattern], ...) -> int: ...
|
|
121
|
+
def send(self, s: str) -> int: ...
|
|
122
|
+
def sendline(self, s: str = "") -> int: ...
|
|
123
|
+
def sendcontrol(self, char: str) -> int: ...
|
|
124
|
+
def sendeof(self) -> None: ...
|
|
125
|
+
def sendintr(self) -> None: ...
|
|
126
|
+
def read(self, size: int = -1) -> str: ...
|
|
127
|
+
def readline(self) -> str: ...
|
|
128
|
+
def isalive(self) -> bool: ...
|
|
129
|
+
def wait(self) -> int: ...
|
|
130
|
+
def close(self, force: bool = True) -> None: ...
|
|
131
|
+
def terminate(self, force: bool = False) -> bool: ...
|
|
132
|
+
def interact(self, ...) -> None: ...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Fixes over pexpect:**
|
|
136
|
+
- Context manager for automatic cleanup
|
|
137
|
+
- `before` never contains input from `sendline` (#821)
|
|
138
|
+
- No bare except clauses (#826)
|
|
139
|
+
- Proper typing on all methods
|
|
140
|
+
|
|
141
|
+
### 4. Pattern Matching (`_expect.py`)
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
Pattern = str | re.Pattern[str] | type[EOF_TYPE] | type[TIMEOUT_TYPE]
|
|
145
|
+
|
|
146
|
+
def compile_pattern(pattern: Pattern) -> CompiledPattern: ...
|
|
147
|
+
def expect_loop(spawn: Spawn, patterns: list[CompiledPattern], timeout: float) -> int: ...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Patterns are compiled once. The expect loop uses `select.select()` (Unix) or polling for non-blocking reads.
|
|
151
|
+
|
|
152
|
+
### 5. Async Spawn (`_async.py`)
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
class AsyncSpawn:
|
|
156
|
+
async def expect(self, pattern: Pattern, *, timeout: float = -1) -> int: ...
|
|
157
|
+
async def send(self, s: str) -> int: ...
|
|
158
|
+
async def sendline(self, s: str = "") -> int: ...
|
|
159
|
+
async def read(self, size: int = -1) -> str: ...
|
|
160
|
+
async def __aenter__(self) -> AsyncSpawn: ...
|
|
161
|
+
async def __aexit__(self, ...) -> None: ...
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Fix over pexpect:** Native async/await, not retrofitted `async_=True` parameter. Uses `asyncio.get_event_loop().add_reader()` for non-blocking PTY reads. No deprecated `@asyncio.coroutine`.
|
|
165
|
+
|
|
166
|
+
### 6. SSH Helper (`_ssh.py`)
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
class SSHSession:
|
|
170
|
+
def __init__(self, server: str, *, username: str | None = None, port: int = 22, ...) -> None: ...
|
|
171
|
+
def login(self, ...) -> None: ...
|
|
172
|
+
def prompt(self, timeout: float = -1) -> bool: ...
|
|
173
|
+
def run(self, command: str, *, timeout: float = -1) -> str: ...
|
|
174
|
+
def logout(self) -> None: ...
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Wraps Spawn for SSH connections with login automation and prompt detection.
|
|
178
|
+
|
|
179
|
+
### 7. Popen Spawn (`_popen.py`)
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
class PopenSpawn:
|
|
183
|
+
"""Non-PTY spawn using subprocess.Popen. Works on Windows."""
|
|
184
|
+
# Same interface as Spawn but uses pipes instead of PTY
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 8. Compatibility Shim (`_compat.py`)
|
|
188
|
+
|
|
189
|
+
Drop-in replacement for code using `import pexpect`:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
# ptylink.compat provides all pexpect public names
|
|
193
|
+
from ptylink.compat import spawn, run, EOF, TIMEOUT
|
|
194
|
+
from ptylink.compat import pxssh # maps to ptylink.SSHSession
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Phases
|
|
198
|
+
|
|
199
|
+
### Phase 1: Core — PTY, Spawn, Expect (Priority: highest)
|
|
200
|
+
- Exception hierarchy
|
|
201
|
+
- PTY process management (fork, pty, signals)
|
|
202
|
+
- Spawn class with send/sendline/expect/expect_exact
|
|
203
|
+
- Pattern matching engine (regex, literal, EOF, TIMEOUT)
|
|
204
|
+
- Timeout handling
|
|
205
|
+
- Context manager (with/as)
|
|
206
|
+
- before/after/match state
|
|
207
|
+
- isalive/wait/close/terminate
|
|
208
|
+
- Unit tests for all core functionality
|
|
209
|
+
|
|
210
|
+
### Phase 2: Advanced Features
|
|
211
|
+
- expect_list (multiple pattern matching)
|
|
212
|
+
- sendcontrol/sendeof/sendintr
|
|
213
|
+
- interact() mode
|
|
214
|
+
- run() high-level function
|
|
215
|
+
- Logging/debugging support
|
|
216
|
+
- Window size management (setwinsize)
|
|
217
|
+
- ANSI escape sequence handling
|
|
218
|
+
- Unit tests for advanced features
|
|
219
|
+
|
|
220
|
+
### Phase 3: Extensions
|
|
221
|
+
- AsyncSpawn — native async/await
|
|
222
|
+
- PopenSpawn — non-PTY (pipes, Windows support)
|
|
223
|
+
- SSHSession — SSH login/command helper
|
|
224
|
+
- Unit tests for extensions
|
|
225
|
+
|
|
226
|
+
### Phase 4: Quality & Compatibility
|
|
227
|
+
- pexpect compatibility shim
|
|
228
|
+
- Cross-verification tests (run same scenarios with pexpect and ptylink)
|
|
229
|
+
- mypy --strict and pyright clean
|
|
230
|
+
- Benchmarks vs pexpect
|
|
231
|
+
- CI (GitHub Actions: Python 3.10, 3.11, 3.12, 3.13, ubuntu + macos)
|
|
232
|
+
- pyproject.toml with full metadata
|
|
233
|
+
|
|
234
|
+
### Phase 5: Documentation & Release
|
|
235
|
+
- README.md with migration guide from pexpect
|
|
236
|
+
- CHANGELOG.md
|
|
237
|
+
- API reference (docstrings, all public functions)
|
|
238
|
+
- Release v0.1.0 to PyPI
|
|
239
|
+
|
|
240
|
+
## Deliverables
|
|
241
|
+
|
|
242
|
+
- `projects/ptylink/` — complete Python package
|
|
243
|
+
- Process spawning with PTY allocation
|
|
244
|
+
- Expect-style pattern matching on process output
|
|
245
|
+
- Native async/await support
|
|
246
|
+
- SSH session helper
|
|
247
|
+
- PopenSpawn for Windows/pipe-based operation
|
|
248
|
+
- pexpect compatibility shim
|
|
249
|
+
- 100% type-annotated, mypy/pyright strict clean
|
|
250
|
+
- Comprehensive test suite with cross-verification
|
|
251
|
+
- Benchmarks vs pexpect
|
|
252
|
+
- CI pipeline
|
|
253
|
+
- README with pexpect migration guide
|
|
254
|
+
|
|
255
|
+
## Success Criteria
|
|
256
|
+
|
|
257
|
+
- `ptylink.spawn("python3")` spawns process with PTY, expect/send works
|
|
258
|
+
- `child.expect(r">>> ")` matches Python REPL prompt
|
|
259
|
+
- `child.sendline("print('hello')")` sends input correctly
|
|
260
|
+
- `child.before` contains output without leaked input (#821 fix)
|
|
261
|
+
- `async with ptylink.AsyncSpawn("cmd") as child:` works with native async
|
|
262
|
+
- `with ptylink.spawn("cmd") as child:` auto-cleans up on exit
|
|
263
|
+
- No Python 3.12+ fork warnings (#817)
|
|
264
|
+
- mypy --strict passes with zero errors
|
|
265
|
+
- pytest passes on Python 3.10, 3.11, 3.12, 3.13
|
|
266
|
+
- Benchmark within 2x of pexpect performance (targeting parity)
|