ntermqt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- ntermqt-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-platform PTY for interactive SSH.
|
|
3
|
+
|
|
4
|
+
Provides a unified interface for pseudo-terminal operations
|
|
5
|
+
on both Unix (pty module) and Windows (pywinpty/ConPTY).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import subprocess
|
|
12
|
+
import logging
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Platform detection
|
|
19
|
+
IS_WINDOWS = sys.platform == 'win32'
|
|
20
|
+
|
|
21
|
+
# Try pexpect first on Unix (most reliable for capturing /dev/tty)
|
|
22
|
+
HAS_PEXPECT = False
|
|
23
|
+
if not IS_WINDOWS:
|
|
24
|
+
try:
|
|
25
|
+
import pexpect
|
|
26
|
+
HAS_PEXPECT = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
logger.info("pexpect not installed - falling back to pty module")
|
|
29
|
+
|
|
30
|
+
if IS_WINDOWS:
|
|
31
|
+
try:
|
|
32
|
+
from winpty import PTY as WinPTY
|
|
33
|
+
HAS_WINPTY = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
HAS_WINPTY = False
|
|
36
|
+
logger.warning("pywinpty not installed - interactive SSH unavailable on Windows")
|
|
37
|
+
else:
|
|
38
|
+
import pty
|
|
39
|
+
import tty
|
|
40
|
+
import fcntl
|
|
41
|
+
import termios
|
|
42
|
+
import struct
|
|
43
|
+
import select
|
|
44
|
+
HAS_WINPTY = False # Not needed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PTYTransport(ABC):
|
|
48
|
+
"""
|
|
49
|
+
Abstract PTY interface.
|
|
50
|
+
|
|
51
|
+
Provides cross-platform pseudo-terminal functionality for
|
|
52
|
+
spawning and interacting with processes that require a TTY.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Spawn process with PTY.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
command: Command and arguments to execute
|
|
62
|
+
env: Optional environment variables
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def read(self, size: int = 4096) -> bytes:
|
|
68
|
+
"""
|
|
69
|
+
Read from PTY (non-blocking).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
size: Maximum bytes to read
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Bytes read, empty if nothing available
|
|
76
|
+
"""
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def write(self, data: bytes) -> int:
|
|
81
|
+
"""
|
|
82
|
+
Write to PTY.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
data: Bytes to write
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Number of bytes written
|
|
89
|
+
"""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Resize PTY.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
cols: Number of columns
|
|
99
|
+
rows: Number of rows
|
|
100
|
+
"""
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
"""Close PTY and terminate process."""
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def is_alive(self) -> bool:
|
|
111
|
+
"""Check if process is still running."""
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def exit_code(self) -> Optional[int]:
|
|
117
|
+
"""Get exit code if process has terminated."""
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class UnixPTY(PTYTransport):
|
|
122
|
+
"""
|
|
123
|
+
Unix PTY implementation using subprocess + pty.
|
|
124
|
+
|
|
125
|
+
Uses subprocess.Popen with start_new_session=True and preexec_fn
|
|
126
|
+
to properly set up the controlling terminal.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self):
|
|
130
|
+
self._master_fd: Optional[int] = None
|
|
131
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
132
|
+
self._exit_code: Optional[int] = None
|
|
133
|
+
|
|
134
|
+
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
135
|
+
"""Spawn process with PTY using subprocess."""
|
|
136
|
+
|
|
137
|
+
# Merge environment
|
|
138
|
+
spawn_env = os.environ.copy()
|
|
139
|
+
if env:
|
|
140
|
+
spawn_env.update(env)
|
|
141
|
+
spawn_env['TERM'] = 'xterm-256color'
|
|
142
|
+
|
|
143
|
+
# Create pseudo-terminal pair
|
|
144
|
+
self._master_fd, slave_fd = pty.openpty()
|
|
145
|
+
slave_name = os.ttyname(slave_fd)
|
|
146
|
+
spawn_env['SSH_TTY'] = slave_name
|
|
147
|
+
|
|
148
|
+
# Determine TIOCSCTTY value for this platform
|
|
149
|
+
import platform
|
|
150
|
+
if platform.system() == 'Darwin':
|
|
151
|
+
tiocsctty = 0x20007461
|
|
152
|
+
else:
|
|
153
|
+
tiocsctty = 0x540E
|
|
154
|
+
|
|
155
|
+
def setup_child():
|
|
156
|
+
"""
|
|
157
|
+
Called in child process after fork, before exec.
|
|
158
|
+
Sets up the PTY as the controlling terminal.
|
|
159
|
+
"""
|
|
160
|
+
# Close the slave fd we inherited - we'll open it fresh
|
|
161
|
+
try:
|
|
162
|
+
os.close(slave_fd)
|
|
163
|
+
except:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# start_new_session=True already called setsid()
|
|
167
|
+
# Now open the slave device fresh - first open after setsid
|
|
168
|
+
# becomes controlling terminal on Linux
|
|
169
|
+
new_slave = os.open(slave_name, os.O_RDWR)
|
|
170
|
+
|
|
171
|
+
# Explicitly set as controlling terminal (needed on some systems)
|
|
172
|
+
try:
|
|
173
|
+
fcntl.ioctl(new_slave, tiocsctty, 0)
|
|
174
|
+
except (OSError, IOError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
# Redirect stdin/stdout/stderr to the PTY
|
|
178
|
+
os.dup2(new_slave, 0)
|
|
179
|
+
os.dup2(new_slave, 1)
|
|
180
|
+
os.dup2(new_slave, 2)
|
|
181
|
+
|
|
182
|
+
if new_slave > 2:
|
|
183
|
+
os.close(new_slave)
|
|
184
|
+
|
|
185
|
+
# Spawn the process
|
|
186
|
+
self._proc = subprocess.Popen(
|
|
187
|
+
command,
|
|
188
|
+
stdin=slave_fd,
|
|
189
|
+
stdout=slave_fd,
|
|
190
|
+
stderr=slave_fd,
|
|
191
|
+
start_new_session=True, # Calls setsid()
|
|
192
|
+
preexec_fn=setup_child,
|
|
193
|
+
env=spawn_env,
|
|
194
|
+
close_fds=True,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Close slave in parent
|
|
198
|
+
os.close(slave_fd)
|
|
199
|
+
|
|
200
|
+
# Set master to non-blocking
|
|
201
|
+
flags = fcntl.fcntl(self._master_fd, fcntl.F_GETFL)
|
|
202
|
+
fcntl.fcntl(self._master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
203
|
+
|
|
204
|
+
logger.debug(f"Spawned PID {self._proc.pid}: {' '.join(command)}")
|
|
205
|
+
|
|
206
|
+
def read(self, size: int = 4096) -> bytes:
|
|
207
|
+
"""Read from PTY (non-blocking)."""
|
|
208
|
+
if self._master_fd is None:
|
|
209
|
+
return b''
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
# Check if data available
|
|
213
|
+
r, _, _ = select.select([self._master_fd], [], [], 0)
|
|
214
|
+
if not r:
|
|
215
|
+
return b''
|
|
216
|
+
return os.read(self._master_fd, size)
|
|
217
|
+
except (BlockingIOError, InterruptedError):
|
|
218
|
+
return b''
|
|
219
|
+
except OSError as e:
|
|
220
|
+
# EIO typically means child exited
|
|
221
|
+
if e.errno == 5: # EIO
|
|
222
|
+
self._check_exit()
|
|
223
|
+
return b''
|
|
224
|
+
|
|
225
|
+
def write(self, data: bytes) -> int:
|
|
226
|
+
"""Write to PTY."""
|
|
227
|
+
if self._master_fd is None:
|
|
228
|
+
return 0
|
|
229
|
+
try:
|
|
230
|
+
return os.write(self._master_fd, data)
|
|
231
|
+
except OSError:
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
235
|
+
"""Resize PTY using TIOCSWINSZ ioctl."""
|
|
236
|
+
if self._master_fd is not None:
|
|
237
|
+
try:
|
|
238
|
+
winsize = struct.pack('HHHH', rows, cols, 0, 0)
|
|
239
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
|
240
|
+
except OSError as e:
|
|
241
|
+
logger.warning(f"Resize failed: {e}")
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
"""Close PTY and terminate process."""
|
|
245
|
+
if self._master_fd is not None:
|
|
246
|
+
try:
|
|
247
|
+
os.close(self._master_fd)
|
|
248
|
+
except OSError:
|
|
249
|
+
pass
|
|
250
|
+
self._master_fd = None
|
|
251
|
+
|
|
252
|
+
if self._proc is not None:
|
|
253
|
+
try:
|
|
254
|
+
self._proc.terminate() # SIGTERM
|
|
255
|
+
except OSError:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# Wait briefly, then force kill
|
|
259
|
+
import time
|
|
260
|
+
for _ in range(10):
|
|
261
|
+
if self._proc.poll() is not None:
|
|
262
|
+
break
|
|
263
|
+
time.sleep(0.1)
|
|
264
|
+
|
|
265
|
+
if self._proc.poll() is None:
|
|
266
|
+
try:
|
|
267
|
+
self._proc.kill() # SIGKILL
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
self._check_exit()
|
|
272
|
+
|
|
273
|
+
def _check_exit(self) -> None:
|
|
274
|
+
"""Check and store exit status."""
|
|
275
|
+
if self._proc is not None and self._exit_code is None:
|
|
276
|
+
retcode = self._proc.poll()
|
|
277
|
+
if retcode is not None:
|
|
278
|
+
self._exit_code = retcode
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def is_alive(self) -> bool:
|
|
282
|
+
"""Check if process is still running."""
|
|
283
|
+
if self._proc is None:
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
return self._proc.poll() is None
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def exit_code(self) -> Optional[int]:
|
|
290
|
+
"""Get exit code if process has terminated."""
|
|
291
|
+
self._check_exit()
|
|
292
|
+
return self._exit_code
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class WindowsPTY(PTYTransport):
|
|
296
|
+
"""
|
|
297
|
+
Windows ConPTY implementation using pywinpty.
|
|
298
|
+
|
|
299
|
+
Requires Windows 10 1809+ and pywinpty package.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(self):
|
|
303
|
+
if not HAS_WINPTY:
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
"pywinpty not installed. Install with: pip install pywinpty"
|
|
306
|
+
)
|
|
307
|
+
self._pty = None
|
|
308
|
+
self._exit_code: Optional[int] = None
|
|
309
|
+
self._cols = 120
|
|
310
|
+
self._rows = 40
|
|
311
|
+
|
|
312
|
+
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
313
|
+
"""Spawn process with ConPTY."""
|
|
314
|
+
# pywinpty wants command as string
|
|
315
|
+
cmd_str = subprocess.list2cmdline(command)
|
|
316
|
+
|
|
317
|
+
# Merge environment
|
|
318
|
+
spawn_env = os.environ.copy()
|
|
319
|
+
if env:
|
|
320
|
+
spawn_env.update(env)
|
|
321
|
+
spawn_env.setdefault('TERM', 'xterm-256color')
|
|
322
|
+
|
|
323
|
+
# Create PTY
|
|
324
|
+
self._pty = WinPTY(
|
|
325
|
+
cols=self._cols,
|
|
326
|
+
rows=self._rows,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Spawn process
|
|
330
|
+
self._pty.spawn(cmd_str, env=spawn_env)
|
|
331
|
+
logger.debug(f"Spawned: {cmd_str}")
|
|
332
|
+
|
|
333
|
+
def read(self, size: int = 4096) -> bytes:
|
|
334
|
+
"""Read from PTY (non-blocking)."""
|
|
335
|
+
if self._pty is None:
|
|
336
|
+
return b''
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
# pywinpty read with timeout=0 for non-blocking
|
|
340
|
+
data = self._pty.read(size, blocking=False)
|
|
341
|
+
if data:
|
|
342
|
+
# pywinpty returns str, encode to bytes
|
|
343
|
+
if isinstance(data, str):
|
|
344
|
+
return data.encode('utf-8', errors='replace')
|
|
345
|
+
return data
|
|
346
|
+
return b''
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.debug(f"Read error: {e}")
|
|
349
|
+
return b''
|
|
350
|
+
|
|
351
|
+
def write(self, data: bytes) -> int:
|
|
352
|
+
"""Write to PTY."""
|
|
353
|
+
if self._pty is None:
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
# pywinpty expects str
|
|
358
|
+
if isinstance(data, bytes):
|
|
359
|
+
text = data.decode('utf-8', errors='replace')
|
|
360
|
+
else:
|
|
361
|
+
text = data
|
|
362
|
+
self._pty.write(text)
|
|
363
|
+
return len(data)
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.debug(f"Write error: {e}")
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
369
|
+
"""Resize ConPTY."""
|
|
370
|
+
self._cols = cols
|
|
371
|
+
self._rows = rows
|
|
372
|
+
if self._pty is not None:
|
|
373
|
+
try:
|
|
374
|
+
self._pty.set_size(cols, rows)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
logger.warning(f"Resize failed: {e}")
|
|
377
|
+
|
|
378
|
+
def close(self) -> None:
|
|
379
|
+
"""Close PTY."""
|
|
380
|
+
if self._pty is not None:
|
|
381
|
+
try:
|
|
382
|
+
# Check exit status before closing
|
|
383
|
+
if not self._pty.isalive():
|
|
384
|
+
self._exit_code = self._pty.get_exitstatus()
|
|
385
|
+
self._pty.close()
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.debug(f"Close error: {e}")
|
|
388
|
+
self._pty = None
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def is_alive(self) -> bool:
|
|
392
|
+
"""Check if process is still running."""
|
|
393
|
+
if self._pty is None:
|
|
394
|
+
return False
|
|
395
|
+
try:
|
|
396
|
+
alive = self._pty.isalive()
|
|
397
|
+
if not alive and self._exit_code is None:
|
|
398
|
+
self._exit_code = self._pty.get_exitstatus()
|
|
399
|
+
return alive
|
|
400
|
+
except:
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def exit_code(self) -> Optional[int]:
|
|
405
|
+
"""Get exit code if process has terminated."""
|
|
406
|
+
if self._exit_code is None and self._pty is not None:
|
|
407
|
+
if not self.is_alive:
|
|
408
|
+
try:
|
|
409
|
+
self._exit_code = self._pty.get_exitstatus()
|
|
410
|
+
except:
|
|
411
|
+
pass
|
|
412
|
+
return self._exit_code
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class PexpectPTY(PTYTransport):
|
|
416
|
+
"""
|
|
417
|
+
PTY implementation using pexpect.
|
|
418
|
+
|
|
419
|
+
pexpect is specifically designed to handle interactive programs
|
|
420
|
+
and properly captures /dev/tty output. This is the most reliable
|
|
421
|
+
method for capturing SSH keyboard-interactive prompts, YubiKey
|
|
422
|
+
challenges, etc.
|
|
423
|
+
|
|
424
|
+
Requires: pip install pexpect
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(self):
|
|
428
|
+
if not HAS_PEXPECT:
|
|
429
|
+
raise RuntimeError(
|
|
430
|
+
"pexpect not installed. Install with: pip install pexpect"
|
|
431
|
+
)
|
|
432
|
+
self._child: Optional[pexpect.spawn] = None
|
|
433
|
+
self._exit_code: Optional[int] = None
|
|
434
|
+
self._cols = 120
|
|
435
|
+
self._rows = 40
|
|
436
|
+
|
|
437
|
+
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
438
|
+
"""Spawn process using pexpect."""
|
|
439
|
+
# Merge environment
|
|
440
|
+
spawn_env = os.environ.copy()
|
|
441
|
+
if env:
|
|
442
|
+
spawn_env.update(env)
|
|
443
|
+
spawn_env['TERM'] = 'xterm-256color'
|
|
444
|
+
|
|
445
|
+
# pexpect.spawn takes command as string or list
|
|
446
|
+
# Using list form for proper argument handling
|
|
447
|
+
cmd = command[0]
|
|
448
|
+
args = command[1:] if len(command) > 1 else []
|
|
449
|
+
|
|
450
|
+
self._child = pexpect.spawn(
|
|
451
|
+
cmd,
|
|
452
|
+
args=args,
|
|
453
|
+
env=spawn_env,
|
|
454
|
+
encoding=None, # Binary mode
|
|
455
|
+
dimensions=(self._rows, self._cols),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Disable echo since terminal handles display
|
|
459
|
+
self._child.setecho(False)
|
|
460
|
+
|
|
461
|
+
logger.debug(f"Spawned via pexpect: {' '.join(command)}")
|
|
462
|
+
|
|
463
|
+
def read(self, size: int = 4096) -> bytes:
|
|
464
|
+
"""Read from PTY (non-blocking)."""
|
|
465
|
+
if self._child is None:
|
|
466
|
+
return b''
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# Use read_nonblocking for non-blocking read
|
|
470
|
+
data = self._child.read_nonblocking(size, timeout=0)
|
|
471
|
+
return data if data else b''
|
|
472
|
+
except pexpect.TIMEOUT:
|
|
473
|
+
return b''
|
|
474
|
+
except pexpect.EOF:
|
|
475
|
+
self._check_exit()
|
|
476
|
+
return b''
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.debug(f"Read error: {e}")
|
|
479
|
+
return b''
|
|
480
|
+
|
|
481
|
+
def write(self, data: bytes) -> int:
|
|
482
|
+
"""Write to PTY."""
|
|
483
|
+
if self._child is None:
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
self._child.send(data)
|
|
488
|
+
return len(data)
|
|
489
|
+
except Exception as e:
|
|
490
|
+
logger.debug(f"Write error: {e}")
|
|
491
|
+
return 0
|
|
492
|
+
|
|
493
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
494
|
+
"""Resize PTY."""
|
|
495
|
+
self._cols = cols
|
|
496
|
+
self._rows = rows
|
|
497
|
+
if self._child is not None:
|
|
498
|
+
try:
|
|
499
|
+
self._child.setwinsize(rows, cols)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.warning(f"Resize failed: {e}")
|
|
502
|
+
|
|
503
|
+
def close(self) -> None:
|
|
504
|
+
"""Close PTY and terminate process."""
|
|
505
|
+
if self._child is not None:
|
|
506
|
+
try:
|
|
507
|
+
self._child.close(force=True)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.debug(f"Close error: {e}")
|
|
510
|
+
self._check_exit()
|
|
511
|
+
self._child = None
|
|
512
|
+
|
|
513
|
+
def _check_exit(self) -> None:
|
|
514
|
+
"""Check and store exit status."""
|
|
515
|
+
if self._child is not None and self._exit_code is None:
|
|
516
|
+
if not self._child.isalive():
|
|
517
|
+
self._exit_code = self._child.exitstatus
|
|
518
|
+
if self._exit_code is None:
|
|
519
|
+
self._exit_code = self._child.signalstatus or -1
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def is_alive(self) -> bool:
|
|
523
|
+
"""Check if process is still running."""
|
|
524
|
+
if self._child is None:
|
|
525
|
+
return False
|
|
526
|
+
alive = self._child.isalive()
|
|
527
|
+
if not alive:
|
|
528
|
+
self._check_exit()
|
|
529
|
+
return alive
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def exit_code(self) -> Optional[int]:
|
|
533
|
+
"""Get exit code if process has terminated."""
|
|
534
|
+
self._check_exit()
|
|
535
|
+
return self._exit_code
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def create_pty(use_pexpect: bool = True) -> PTYTransport:
|
|
539
|
+
"""
|
|
540
|
+
Factory for platform-appropriate PTY.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
use_pexpect: If True and pexpect is available, use PexpectPTY (recommended)
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
PTYTransport instance for current platform
|
|
547
|
+
|
|
548
|
+
Raises:
|
|
549
|
+
RuntimeError: If PTY support not available
|
|
550
|
+
"""
|
|
551
|
+
if IS_WINDOWS:
|
|
552
|
+
if not HAS_WINPTY:
|
|
553
|
+
raise RuntimeError(
|
|
554
|
+
"pywinpty required for Windows. Install with: pip install pywinpty"
|
|
555
|
+
)
|
|
556
|
+
return WindowsPTY()
|
|
557
|
+
|
|
558
|
+
# On Unix, prefer pexpect for best /dev/tty capture
|
|
559
|
+
if use_pexpect and HAS_PEXPECT:
|
|
560
|
+
logger.debug("Using PexpectPTY for best /dev/tty capture")
|
|
561
|
+
return PexpectPTY()
|
|
562
|
+
|
|
563
|
+
logger.debug("Using UnixPTY")
|
|
564
|
+
return UnixPTY()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def is_pty_available() -> bool:
|
|
568
|
+
"""Check if PTY support is available on current platform."""
|
|
569
|
+
if IS_WINDOWS:
|
|
570
|
+
return HAS_WINPTY
|
|
571
|
+
return True # Always available on Unix
|