ntermqt 0.1.0__py3-none-any.whl → 0.1.3__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.
@@ -1,5 +1,5 @@
1
1
  """
2
- Cross-platform PTY for interactive SSH.
2
+ Cross-platform PTY for interactive terminals.
3
3
 
4
4
  Provides a unified interface for pseudo-terminal operations
5
5
  on both Unix (pty module) and Windows (pywinpty/ConPTY).
@@ -47,70 +47,72 @@ else:
47
47
  class PTYTransport(ABC):
48
48
  """
49
49
  Abstract PTY interface.
50
-
50
+
51
51
  Provides cross-platform pseudo-terminal functionality for
52
52
  spawning and interacting with processes that require a TTY.
53
53
  """
54
-
54
+
55
55
  @abstractmethod
56
- def spawn(self, command: list[str], env: dict = None) -> None:
56
+ def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
57
57
  """
58
58
  Spawn process with PTY.
59
-
59
+
60
60
  Args:
61
61
  command: Command and arguments to execute
62
62
  env: Optional environment variables
63
+ echo: Whether to echo input back to terminal. Set False for SSH
64
+ (remote handles echo), True for local shells/apps.
63
65
  """
64
66
  pass
65
-
67
+
66
68
  @abstractmethod
67
69
  def read(self, size: int = 4096) -> bytes:
68
70
  """
69
71
  Read from PTY (non-blocking).
70
-
72
+
71
73
  Args:
72
74
  size: Maximum bytes to read
73
-
75
+
74
76
  Returns:
75
77
  Bytes read, empty if nothing available
76
78
  """
77
79
  pass
78
-
80
+
79
81
  @abstractmethod
80
82
  def write(self, data: bytes) -> int:
81
83
  """
82
84
  Write to PTY.
83
-
85
+
84
86
  Args:
85
87
  data: Bytes to write
86
-
88
+
87
89
  Returns:
88
90
  Number of bytes written
89
91
  """
90
92
  pass
91
-
93
+
92
94
  @abstractmethod
93
95
  def resize(self, cols: int, rows: int) -> None:
94
96
  """
95
97
  Resize PTY.
96
-
98
+
97
99
  Args:
98
100
  cols: Number of columns
99
101
  rows: Number of rows
100
102
  """
101
103
  pass
102
-
104
+
103
105
  @abstractmethod
104
106
  def close(self) -> None:
105
107
  """Close PTY and terminate process."""
106
108
  pass
107
-
109
+
108
110
  @property
109
111
  @abstractmethod
110
112
  def is_alive(self) -> bool:
111
113
  """Check if process is still running."""
112
114
  pass
113
-
115
+
114
116
  @property
115
117
  @abstractmethod
116
118
  def exit_code(self) -> Optional[int]:
@@ -121,37 +123,37 @@ class PTYTransport(ABC):
121
123
  class UnixPTY(PTYTransport):
122
124
  """
123
125
  Unix PTY implementation using subprocess + pty.
124
-
126
+
125
127
  Uses subprocess.Popen with start_new_session=True and preexec_fn
126
128
  to properly set up the controlling terminal.
127
129
  """
128
-
130
+
129
131
  def __init__(self):
130
132
  self._master_fd: Optional[int] = None
131
133
  self._proc: Optional[subprocess.Popen] = None
132
134
  self._exit_code: Optional[int] = None
133
-
134
- def spawn(self, command: list[str], env: dict = None) -> None:
135
+
136
+ def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
135
137
  """Spawn process with PTY using subprocess."""
136
-
138
+
137
139
  # Merge environment
138
140
  spawn_env = os.environ.copy()
139
141
  if env:
140
142
  spawn_env.update(env)
141
143
  spawn_env['TERM'] = 'xterm-256color'
142
-
144
+
143
145
  # Create pseudo-terminal pair
144
146
  self._master_fd, slave_fd = pty.openpty()
145
147
  slave_name = os.ttyname(slave_fd)
146
148
  spawn_env['SSH_TTY'] = slave_name
147
-
149
+
148
150
  # Determine TIOCSCTTY value for this platform
149
151
  import platform
150
152
  if platform.system() == 'Darwin':
151
153
  tiocsctty = 0x20007461
152
154
  else:
153
155
  tiocsctty = 0x540E
154
-
156
+
155
157
  def setup_child():
156
158
  """
157
159
  Called in child process after fork, before exec.
@@ -162,52 +164,52 @@ class UnixPTY(PTYTransport):
162
164
  os.close(slave_fd)
163
165
  except:
164
166
  pass
165
-
167
+
166
168
  # start_new_session=True already called setsid()
167
- # Now open the slave device fresh - first open after setsid
169
+ # Now open the slave device fresh - first open after setsid
168
170
  # becomes controlling terminal on Linux
169
171
  new_slave = os.open(slave_name, os.O_RDWR)
170
-
172
+
171
173
  # Explicitly set as controlling terminal (needed on some systems)
172
174
  try:
173
175
  fcntl.ioctl(new_slave, tiocsctty, 0)
174
176
  except (OSError, IOError):
175
177
  pass
176
-
178
+
177
179
  # Redirect stdin/stdout/stderr to the PTY
178
180
  os.dup2(new_slave, 0)
179
- os.dup2(new_slave, 1)
181
+ os.dup2(new_slave, 1)
180
182
  os.dup2(new_slave, 2)
181
-
183
+
182
184
  if new_slave > 2:
183
185
  os.close(new_slave)
184
-
186
+
185
187
  # Spawn the process
186
188
  self._proc = subprocess.Popen(
187
189
  command,
188
190
  stdin=slave_fd,
189
191
  stdout=slave_fd,
190
192
  stderr=slave_fd,
191
- start_new_session=True, # Calls setsid()
193
+ start_new_session=True, # Calls setsid()
192
194
  preexec_fn=setup_child,
193
195
  env=spawn_env,
194
196
  close_fds=True,
195
197
  )
196
-
198
+
197
199
  # Close slave in parent
198
200
  os.close(slave_fd)
199
-
201
+
200
202
  # Set master to non-blocking
201
203
  flags = fcntl.fcntl(self._master_fd, fcntl.F_GETFL)
202
204
  fcntl.fcntl(self._master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
203
-
205
+
204
206
  logger.debug(f"Spawned PID {self._proc.pid}: {' '.join(command)}")
205
-
207
+
206
208
  def read(self, size: int = 4096) -> bytes:
207
209
  """Read from PTY (non-blocking)."""
208
210
  if self._master_fd is None:
209
211
  return b''
210
-
212
+
211
213
  try:
212
214
  # Check if data available
213
215
  r, _, _ = select.select([self._master_fd], [], [], 0)
@@ -221,7 +223,7 @@ class UnixPTY(PTYTransport):
221
223
  if e.errno == 5: # EIO
222
224
  self._check_exit()
223
225
  return b''
224
-
226
+
225
227
  def write(self, data: bytes) -> int:
226
228
  """Write to PTY."""
227
229
  if self._master_fd is None:
@@ -230,7 +232,7 @@ class UnixPTY(PTYTransport):
230
232
  return os.write(self._master_fd, data)
231
233
  except OSError:
232
234
  return 0
233
-
235
+
234
236
  def resize(self, cols: int, rows: int) -> None:
235
237
  """Resize PTY using TIOCSWINSZ ioctl."""
236
238
  if self._master_fd is not None:
@@ -239,7 +241,7 @@ class UnixPTY(PTYTransport):
239
241
  fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
240
242
  except OSError as e:
241
243
  logger.warning(f"Resize failed: {e}")
242
-
244
+
243
245
  def close(self) -> None:
244
246
  """Close PTY and terminate process."""
245
247
  if self._master_fd is not None:
@@ -248,43 +250,43 @@ class UnixPTY(PTYTransport):
248
250
  except OSError:
249
251
  pass
250
252
  self._master_fd = None
251
-
253
+
252
254
  if self._proc is not None:
253
255
  try:
254
256
  self._proc.terminate() # SIGTERM
255
257
  except OSError:
256
258
  pass
257
-
259
+
258
260
  # Wait briefly, then force kill
259
261
  import time
260
262
  for _ in range(10):
261
263
  if self._proc.poll() is not None:
262
264
  break
263
265
  time.sleep(0.1)
264
-
266
+
265
267
  if self._proc.poll() is None:
266
268
  try:
267
269
  self._proc.kill() # SIGKILL
268
270
  except OSError:
269
271
  pass
270
-
272
+
271
273
  self._check_exit()
272
-
274
+
273
275
  def _check_exit(self) -> None:
274
276
  """Check and store exit status."""
275
277
  if self._proc is not None and self._exit_code is None:
276
278
  retcode = self._proc.poll()
277
279
  if retcode is not None:
278
280
  self._exit_code = retcode
279
-
281
+
280
282
  @property
281
283
  def is_alive(self) -> bool:
282
284
  """Check if process is still running."""
283
285
  if self._proc is None:
284
286
  return False
285
-
287
+
286
288
  return self._proc.poll() is None
287
-
289
+
288
290
  @property
289
291
  def exit_code(self) -> Optional[int]:
290
292
  """Get exit code if process has terminated."""
@@ -295,10 +297,10 @@ class UnixPTY(PTYTransport):
295
297
  class WindowsPTY(PTYTransport):
296
298
  """
297
299
  Windows ConPTY implementation using pywinpty.
298
-
300
+
299
301
  Requires Windows 10 1809+ and pywinpty package.
300
302
  """
301
-
303
+
302
304
  def __init__(self):
303
305
  if not HAS_WINPTY:
304
306
  raise RuntimeError(
@@ -308,33 +310,34 @@ class WindowsPTY(PTYTransport):
308
310
  self._exit_code: Optional[int] = None
309
311
  self._cols = 120
310
312
  self._rows = 40
311
-
312
- def spawn(self, command: list[str], env: dict = None) -> None:
313
+
314
+ def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
313
315
  """Spawn process with ConPTY."""
314
316
  # pywinpty wants command as string
315
317
  cmd_str = subprocess.list2cmdline(command)
316
-
318
+
317
319
  # Merge environment
318
320
  spawn_env = os.environ.copy()
319
321
  if env:
320
322
  spawn_env.update(env)
321
323
  spawn_env.setdefault('TERM', 'xterm-256color')
322
-
324
+
323
325
  # Create PTY
324
326
  self._pty = WinPTY(
325
327
  cols=self._cols,
326
328
  rows=self._rows,
327
329
  )
328
-
330
+
329
331
  # Spawn process
332
+ # Note: ConPTY handles echo automatically based on the application
330
333
  self._pty.spawn(cmd_str, env=spawn_env)
331
334
  logger.debug(f"Spawned: {cmd_str}")
332
-
335
+
333
336
  def read(self, size: int = 4096) -> bytes:
334
337
  """Read from PTY (non-blocking)."""
335
338
  if self._pty is None:
336
339
  return b''
337
-
340
+
338
341
  try:
339
342
  # pywinpty read with timeout=0 for non-blocking
340
343
  data = self._pty.read(size, blocking=False)
@@ -347,12 +350,12 @@ class WindowsPTY(PTYTransport):
347
350
  except Exception as e:
348
351
  logger.debug(f"Read error: {e}")
349
352
  return b''
350
-
353
+
351
354
  def write(self, data: bytes) -> int:
352
355
  """Write to PTY."""
353
356
  if self._pty is None:
354
357
  return 0
355
-
358
+
356
359
  try:
357
360
  # pywinpty expects str
358
361
  if isinstance(data, bytes):
@@ -364,7 +367,7 @@ class WindowsPTY(PTYTransport):
364
367
  except Exception as e:
365
368
  logger.debug(f"Write error: {e}")
366
369
  return 0
367
-
370
+
368
371
  def resize(self, cols: int, rows: int) -> None:
369
372
  """Resize ConPTY."""
370
373
  self._cols = cols
@@ -374,7 +377,7 @@ class WindowsPTY(PTYTransport):
374
377
  self._pty.set_size(cols, rows)
375
378
  except Exception as e:
376
379
  logger.warning(f"Resize failed: {e}")
377
-
380
+
378
381
  def close(self) -> None:
379
382
  """Close PTY."""
380
383
  if self._pty is not None:
@@ -386,7 +389,7 @@ class WindowsPTY(PTYTransport):
386
389
  except Exception as e:
387
390
  logger.debug(f"Close error: {e}")
388
391
  self._pty = None
389
-
392
+
390
393
  @property
391
394
  def is_alive(self) -> bool:
392
395
  """Check if process is still running."""
@@ -399,7 +402,7 @@ class WindowsPTY(PTYTransport):
399
402
  return alive
400
403
  except:
401
404
  return False
402
-
405
+
403
406
  @property
404
407
  def exit_code(self) -> Optional[int]:
405
408
  """Get exit code if process has terminated."""
@@ -415,15 +418,15 @@ class WindowsPTY(PTYTransport):
415
418
  class PexpectPTY(PTYTransport):
416
419
  """
417
420
  PTY implementation using pexpect.
418
-
421
+
419
422
  pexpect is specifically designed to handle interactive programs
420
423
  and properly captures /dev/tty output. This is the most reliable
421
424
  method for capturing SSH keyboard-interactive prompts, YubiKey
422
425
  challenges, etc.
423
-
426
+
424
427
  Requires: pip install pexpect
425
428
  """
426
-
429
+
427
430
  def __init__(self):
428
431
  if not HAS_PEXPECT:
429
432
  raise RuntimeError(
@@ -433,20 +436,29 @@ class PexpectPTY(PTYTransport):
433
436
  self._exit_code: Optional[int] = None
434
437
  self._cols = 120
435
438
  self._rows = 40
436
-
437
- def spawn(self, command: list[str], env: dict = None) -> None:
438
- """Spawn process using pexpect."""
439
+
440
+ def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
441
+ """
442
+ Spawn process using pexpect.
443
+
444
+ Args:
445
+ command: Command and arguments to execute
446
+ env: Optional environment variables
447
+ echo: Whether PTY should echo input. Set False for SSH (remote
448
+ PTY handles echo), True for local shells and applications
449
+ like vim that need to see their own input.
450
+ """
439
451
  # Merge environment
440
452
  spawn_env = os.environ.copy()
441
453
  if env:
442
454
  spawn_env.update(env)
443
455
  spawn_env['TERM'] = 'xterm-256color'
444
-
456
+
445
457
  # pexpect.spawn takes command as string or list
446
458
  # Using list form for proper argument handling
447
459
  cmd = command[0]
448
460
  args = command[1:] if len(command) > 1 else []
449
-
461
+
450
462
  self._child = pexpect.spawn(
451
463
  cmd,
452
464
  args=args,
@@ -454,17 +466,19 @@ class PexpectPTY(PTYTransport):
454
466
  encoding=None, # Binary mode
455
467
  dimensions=(self._rows, self._cols),
456
468
  )
457
-
458
- # Disable echo since terminal handles display
459
- self._child.setecho(False)
460
-
461
- logger.debug(f"Spawned via pexpect: {' '.join(command)}")
462
-
469
+
470
+ # Control echo based on use case:
471
+ # - SSH: echo=False (remote PTY handles echo)
472
+ # - Local shell/apps: echo=True (local PTY must echo)
473
+ self._child.setecho(echo)
474
+
475
+ logger.debug(f"Spawned via pexpect (echo={echo}): {' '.join(command)}")
476
+
463
477
  def read(self, size: int = 4096) -> bytes:
464
478
  """Read from PTY (non-blocking)."""
465
479
  if self._child is None:
466
480
  return b''
467
-
481
+
468
482
  try:
469
483
  # Use read_nonblocking for non-blocking read
470
484
  data = self._child.read_nonblocking(size, timeout=0)
@@ -477,19 +491,19 @@ class PexpectPTY(PTYTransport):
477
491
  except Exception as e:
478
492
  logger.debug(f"Read error: {e}")
479
493
  return b''
480
-
494
+
481
495
  def write(self, data: bytes) -> int:
482
496
  """Write to PTY."""
483
497
  if self._child is None:
484
498
  return 0
485
-
499
+
486
500
  try:
487
501
  self._child.send(data)
488
502
  return len(data)
489
503
  except Exception as e:
490
504
  logger.debug(f"Write error: {e}")
491
505
  return 0
492
-
506
+
493
507
  def resize(self, cols: int, rows: int) -> None:
494
508
  """Resize PTY."""
495
509
  self._cols = cols
@@ -499,7 +513,7 @@ class PexpectPTY(PTYTransport):
499
513
  self._child.setwinsize(rows, cols)
500
514
  except Exception as e:
501
515
  logger.warning(f"Resize failed: {e}")
502
-
516
+
503
517
  def close(self) -> None:
504
518
  """Close PTY and terminate process."""
505
519
  if self._child is not None:
@@ -509,7 +523,7 @@ class PexpectPTY(PTYTransport):
509
523
  logger.debug(f"Close error: {e}")
510
524
  self._check_exit()
511
525
  self._child = None
512
-
526
+
513
527
  def _check_exit(self) -> None:
514
528
  """Check and store exit status."""
515
529
  if self._child is not None and self._exit_code is None:
@@ -517,7 +531,7 @@ class PexpectPTY(PTYTransport):
517
531
  self._exit_code = self._child.exitstatus
518
532
  if self._exit_code is None:
519
533
  self._exit_code = self._child.signalstatus or -1
520
-
534
+
521
535
  @property
522
536
  def is_alive(self) -> bool:
523
537
  """Check if process is still running."""
@@ -527,7 +541,7 @@ class PexpectPTY(PTYTransport):
527
541
  if not alive:
528
542
  self._check_exit()
529
543
  return alive
530
-
544
+
531
545
  @property
532
546
  def exit_code(self) -> Optional[int]:
533
547
  """Get exit code if process has terminated."""
@@ -538,13 +552,13 @@ class PexpectPTY(PTYTransport):
538
552
  def create_pty(use_pexpect: bool = True) -> PTYTransport:
539
553
  """
540
554
  Factory for platform-appropriate PTY.
541
-
555
+
542
556
  Args:
543
557
  use_pexpect: If True and pexpect is available, use PexpectPTY (recommended)
544
-
558
+
545
559
  Returns:
546
560
  PTYTransport instance for current platform
547
-
561
+
548
562
  Raises:
549
563
  RuntimeError: If PTY support not available
550
564
  """
@@ -554,12 +568,12 @@ def create_pty(use_pexpect: bool = True) -> PTYTransport:
554
568
  "pywinpty required for Windows. Install with: pip install pywinpty"
555
569
  )
556
570
  return WindowsPTY()
557
-
571
+
558
572
  # On Unix, prefer pexpect for best /dev/tty capture
559
573
  if use_pexpect and HAS_PEXPECT:
560
574
  logger.debug("Using PexpectPTY for best /dev/tty capture")
561
575
  return PexpectPTY()
562
-
576
+
563
577
  logger.debug("Using UnixPTY")
564
578
  return UnixPTY()
565
579
 
@@ -568,4 +582,4 @@ def is_pty_available() -> bool:
568
582
  """Check if PTY support is available on current platform."""
569
583
  if IS_WINDOWS:
570
584
  return HAS_WINPTY
571
- return True # Always available on Unix
585
+ return True # Always available on Unix