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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. 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