htty
htty - a wrapper around ht
Some terminal applications don't make it easy to capture their output in a human-readable way. Here's vim's startup screen:
~ VIM - Vi IMproved
~ version 9.1.1336
~ by Bram Moolenaar et al.
~ Vim is open source and freely distributable
~
~ Help poor children in Uganda!
If you capture vim's ouput directly, you won't get the nicely formatted text you see above. Instead, you'll get raw ANSI escape sequences.
Vi IMproved[6;37Hversion 9.0.2136[7;33Hby Bram Moolenaar et al.[8;24HVim is open source and freely distributable[10;32HHelp poor children in Uganda!
htty makes it possible to get a human-friendly string representing the contents of a terminal, without having an actual graphical terminal emulator in the loop.
To do this, it connects processes (like vim) to a pseudoterminal interface which directs output to an ANSI interpreter. Most ANSI interpreters are involved with putting characters on a screen for humans to view directly, but this one is headless, so the text is stored internally for later reference.
htty lets you control the underlying process and take snapshots of the headless terminal's contents at times when you expect it to be interesting. This can be handy for testing, like when you want to assert that the user's terminal looks a certain way, or for when you have a large amount of subprocess output and you want to show your user only a certain part of it.
It's a bit like a zoomed-out grep: Instead of finding lines of a file, it finds snapshots of a terminal session.
Library Usage
The terminal_session
context manager yields a HtWrapper
object which has methods for communicating with the underlying ht
process.
from htty import Press, terminal_session
# start an interactive bourne shell in a small headless terminal
with terminal_session("sh -i", rows=4, cols=6) as sh:
# print enough so that the prompt is at the bottom of the screen
sh.send_keys([r"printf '\n\n\n\nhello world\n'", Press.ENTER])
sh.expect("world")
hello = sh.snapshot()
# clear the terminal
sh.send_keys(["clear", Press.ENTER])
sh.expect_absent("world")
sh.expect("\$")
cleared = sh.snapshot()
# assert correct placement
assert hello.text == '\n'.join([
" ", # line wrap after 6 chars
"hello ",
"world ",
"$ ", # four rows high
])
# assert that clear... cleared
assert cleared.text == '\n'.join([
"$ ",
" ",
" ",
" ",
])
It's a good idea to expect
something before you take a snapshot, otherwise the snapshot might happen before the child process has fully arrived at the state you're trying to capture.
Command Line Usage
Unlike the htty
python library, the htty
command accepts all of its instructions before it starts.
It will
1. run them all, printing snapshots along the way
2. terminate the child process
3. exit
If you're looking for something that doesn't clean the process up afterwards, consider one of these:
- run
ht
instead ofhtty
in a shell (usage) - use
htty
as a python library - other terminal emulator libraries such as pyte
$ htty --help
usage: htty [-h] [-r ROWS] [-c COLS] [-k KEYS] [-s] [-d DELIMITER] [--debug]
[--expect EXPECT] [--expect-absent EXPECT_ABSENT] [--version]
[command ...]
Run a command with ht terminal emulation (synchronous mode)
positional arguments:
command Command to run (must be preceded by --)
options:
-h, --help show this help message and exit
-r ROWS, --rows ROWS Number of terminal rows (default: 20)
-c COLS, --cols COLS Number of terminal columns (default: 50)
-k KEYS, --keys KEYS Send keys to the terminal. Can be used multiple times.
-s, --snapshot Take a snapshot of terminal output. Can be used
multiple times.
-d DELIMITER, --delimiter DELIMITER
Delimiter for parsing keys (default: ',')
--debug Enable debug mode: show ht events and subscribe to
debug events
--expect EXPECT Wait for a regex pattern to appear in the terminal
output. Can be used multiple times.
--expect-absent EXPECT_ABSENT
Wait for a regex pattern to disappear from the
terminal output. Can be used multiple times.
--version show program's version number and exit
Examples:
htty -- echo hello
htty -k "hello,Enter" -s -- vim
htty -r 30 -c 80 -s -k "ihello,Escape" -s -- vim
The -k/--keys, -s/--snapshot, --expect, and --expect-absent options can be used multiple times and will be
processed in order.
The sl
command animates an ascii-art train engine driving from right to left across your terminal.
Near the middle of the engine are some I
's an further back is a Y
.
htty
can use the appearance and dissapearance of these characters to trigger snapshots of the train.
The command below wraps sl
, and captures two snapshots (triggered by Y appearing and I dissapering).
ints them to stdout with a '----' to indicate the end of each snapshot.
$ htty -r 15 -c 50 --expect Y --snapshot --expect-absent I --snapshot -- sl
(@@@)
==== ________
_D _| |_______/ \__I_I_____==
|(_)--- | H\________/ | |
/ | | H | | | |
| | | H |__-----------------
| ________|___H__/__|_____/[][]~\____
|/ | |-----------I_____I [][] [] D
__/ =| o |=-~~\ /~~\ /~~\ /~~\ ____Y
|/-=|___|= O=====O=====O=====O|_____
\_/ \__/ \__/ \__/ \__/
----
___________
_===__|_________|
=|___ ___| _________________
||_| |_|| _| \_____A
------| [___] | =| |
______| | -| |
D |=======|____|________________________|_
__Y___________|__|__________________________|_
___/~\___/ |_D__D__D_| |_D__D__D_|
\_/ \_/ \_/ \_/ \_/
----
Warning: if you don't include an --expect
, it's likely that your first snapshot will be empty because it happens before the command can get around to producing any output.
1""" 2htty - a wrapper around [ht](https://github.com/andyk/ht) 3 4Some terminal applications don't make it easy to capture their output in a human-readable way. 5Here's vim's startup screen: 6 7``` 8~ VIM - Vi IMproved 9~ version 9.1.1336 10~ by Bram Moolenaar et al. 11~ Vim is open source and freely distributable 12~ 13~ Help poor children in Uganda! 14``` 15 16If you capture vim's ouput directly, you won't get the nicely formatted text you see above. 17Instead, you'll get raw ANSI escape sequences. 18 19``` 20Vi IMproved[6;37Hversion 9.0.2136[7;33Hby Bram Moolenaar et al.[8;24HVim is open source and freely distributable[10;32HHelp poor children in Uganda! 21``` 22 23htty makes it possible to get a human-friendly string representing the contents of a terminal, without having an actual graphical terminal emulator in the loop. 24 25To do this, it connects processes (like vim) to a [pseudoterminal interface](https://man7.org/linux/man-pages/man7/pty.7.html) which directs output to an ANSI interpreter. 26Most ANSI interpreters are involved with putting characters on a screen for humans to view directly, but this one is headless, so the text is stored internally for later reference. 27 28htty lets you control the underlying process and take snapshots of the headless terminal's contents at times when you expect it to be interesting. 29This can be handy for testing, like when you want to assert that the user's terminal looks a certain way, or for when you have a large amount of subprocess output and you want to show your user only a certain part of it. 30 31It's a bit like a zoomed-out grep: 32Instead of finding lines of a file, it finds snapshots of a terminal session. 33 34# Library Usage 35 36The `terminal_session` context manager yields a `HtWrapper` object which has methods for communicating with the underlying `ht` process. 37 38```python 39from htty import Press, terminal_session 40 41# start an interactive bourne shell in a small headless terminal 42with terminal_session("sh -i", rows=4, cols=6) as sh: 43 44 # print enough so that the prompt is at the bottom of the screen 45 sh.send_keys([r"printf '\\n\\n\\n\\nhello world\\n'", Press.ENTER]) 46 sh.expect("world") 47 hello = sh.snapshot() 48 49 # clear the terminal 50 sh.send_keys(["clear", Press.ENTER]) 51 sh.expect_absent("world") 52 sh.expect("\\$") 53 cleared = sh.snapshot() 54 55# assert correct placement 56assert hello.text == '\\n'.join([ 57 " ", # line wrap after 6 chars 58 "hello ", 59 "world ", 60 "$ ", # four rows high 61]) 62 63# assert that clear... cleared 64assert cleared.text == '\\n'.join([ 65 "$ ", 66 " ", 67 " ", 68 " ", 69]) 70``` 71It's a good idea to `expect` something before you take a snapshot, otherwise the snapshot might happen before the child process has fully arrived at the state you're trying to capture. 72 73# Command Line Usage 74 75Unlike the `htty` python library, the `htty` command accepts all of its instructions before it starts. 76It will 77 78 1. run them all, printing snapshots along the way 79 2. terminate the child process 80 3. exit 81 82If you're looking for something that doesn't clean the process up afterwards, consider one of these: 83 - run `ht` instead of `htty` in a shell ([usage](https://github.com/andyk/ht?tab=readme-ov-file#usage)) 84 - use `htty` as a python library 85 - other terminal emulator libraries such as [pyte](https://github.com/selectel/pyte) 86 87``` 88$ htty --help 89Error running command: Command '['htty', '--help']' returned non-zero exit status 2. 90``` 91 92The `sl` command animates an ascii-art train engine driving from right to left across your terminal. 93Near the middle of the engine are some `I`'s an further back is a `Y`. 94`htty` can use the appearance and dissapearance of these characters to trigger snapshots of the train. 95 96The command below wraps `sl`, and captures two snapshots (triggered by Y appearing and I dissapering). 97 ints them to stdout with a '----' to indicate the end of each snapshot. 98 99``` 100$ htty -r 15 -c 50 --expect Y --snapshot --expect-absent I --snapshot -- sl 101 102 (@@@) 103 ==== ________ 104 _D _| |_______/ \\__I_I_____== 105 |(_)--- | H\\________/ | | 106 / | | H | | | | 107 | | | H |__----------------- 108 | ________|___H__/__|_____/[][]~\\____ 109 |/ | |-----------I_____I [][] [] D 110 __/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y 111 |/-=|___|= O=====O=====O=====O|_____ 112 \\_/ \\__/ \\__/ \\__/ \\__/ 113 114 115 116---- 117 118 119 ___________ 120_===__|_________| 121 =|___ ___| _________________ 122 ||_| |_|| _| \\_____A 123------| [___] | =| | 124______| | -| | 125 D |=======|____|________________________|_ 126__Y___________|__|__________________________|_ 127___/~\\___/ |_D__D__D_| |_D__D__D_| 128 \\_/ \\_/ \\_/ \\_/ \\_/ 129 130 131 132---- 133``` 134Warning: if you don't include an `--expect`, it's likely that your first snapshot will be empty because it happens before the command can get around to producing any output. 135""" 136 137import htty.keys as keys 138from htty.ht import ( 139 HtWrapper, 140 ProcessController, 141 SnapshotResult, 142 run, 143 terminal_session, 144) 145from htty.keys import Press 146 147# [[[cog 148# import os 149# cog.out(f'__version__ = "{os.environ["HTTY_VERSION"]}"') 150# ]]] 151__version__ = "0.2.25" 152# [[[end]]] 153 154__all__ = [ 155 "terminal_session", 156 "run", 157 "HtWrapper", 158 "ProcessController", 159 "SnapshotResult", 160 "Press", 161 "keys", 162 "__version__", 163]
511@contextmanager 512def terminal_session( 513 command: Command, 514 rows: Rows = None, 515 cols: Cols = None, 516 logger: Logger = None, 517 extra_subscribes: ExtraSubscribes = None, 518) -> Iterator[HtWrapper]: 519 """ 520 The terminal_session context manager is a wrapper around `run` which ensures that the underlying process 521 gets cleaned up: 522 523 ```python 524 with terminal_session("some command") as proc: 525 # interact with the running command here 526 assert proc.exit_code is not None 527 s = proc.snapshot() 528 529 # htty terminates your command on context exit 530 assert proc.exit_code is None 531 assert "hello world" in proc.snapshot() 532 ``` 533 534 Its usage is otherwise the same as `run`. 535 It also returns a 536 537 """ 538 539 proc = run( 540 command, 541 rows=rows, 542 cols=cols, 543 no_exit=True, 544 logger=logger, 545 extra_subscribes=extra_subscribes, 546 ) 547 try: 548 yield proc 549 finally: 550 try: 551 if proc.cmd.pid: 552 proc.cmd.terminate() 553 proc.cmd.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT) 554 except Exception: 555 try: 556 if proc.cmd.pid: 557 proc.cmd.kill() 558 except Exception: 559 pass 560 561 try: 562 proc.ht.terminate() 563 proc.ht.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT) 564 except Exception: 565 with suppress(Exception): 566 proc.ht.kill()
The terminal_session context manager is a wrapper around run
which ensures that the underlying process
gets cleaned up:
with terminal_session("some command") as proc:
# interact with the running command here
assert proc.exit_code is not None
s = proc.snapshot()
# htty terminates your command on context exit
assert proc.exit_code is None
assert "hello world" in proc.snapshot()
Its usage is otherwise the same as run
.
It also returns a
569def run( 570 command: Command, 571 rows: Rows = None, 572 cols: Cols = None, 573 no_exit: NoExit = True, 574 logger: Logger = None, 575 extra_subscribes: ExtraSubscribes = None, 576) -> HtWrapper: 577 """ 578 As a user of the htty python library, your code will run in the python process at the root of this 579 process tree: 580 581 python '{/path/to/your/code.py}' 582 └── ht 583 └── sh -c '{modified command}' 584 585 So if you're using htty to wrap vim, the process tree is: 586 587 ``` 588 python 589 └── ht 590 └── sh 591 └── vim 592 ``` 593 594 For reasons that are documented in [htty-core](./htty-core/htty_core.html#run), the command that ht 595 runs is not: 596 597 sh -c '{command}' 598 599 Instead it's something like this: 600 601 sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/tmp/ht_fifo_5432 ; exit $exit_code' 602 603 This function invokes `ht` as a subprocess such that you end up with a process tree like the one shown 604 above. It returns an `HtWrapper` object which can be used to interact with ht and its child process. 605 606 It's up to you to clean up this process when you're done: 607 608 ```python 609 proc = run("some command") 610 # do stuff 611 proc.exit() 612 ```` 613 If you'd rather not risk having a bunch of `ht` processes lying around and wasting CPU cycles, 614 consider using the `terminal_session` instead. 615 """ 616 # Use provided logger or fall back to default 617 process_logger = logger or default_logger 618 619 # Create a queue for events 620 event_queue: queue.Queue[dict[str, Any]] = queue.Queue() 621 622 # Build the ht subscription list 623 base_subscribes = [ 624 HtEvent.INIT, 625 HtEvent.SNAPSHOT, 626 HtEvent.OUTPUT, 627 HtEvent.RESIZE, 628 HtEvent.PID, 629 HtEvent.EXIT_CODE, 630 HtEvent.COMMAND_COMPLETED, 631 ] 632 if extra_subscribes: 633 # Convert string subscribes to HtEvent enum values 634 for sub in extra_subscribes: 635 try: 636 base_subscribes.append(HtEvent(sub)) 637 except ValueError: 638 process_logger.warning(f"Unknown subscription event: {sub}") 639 640 # Convert command to string if it's a list, properly escaping shell arguments 641 command_str = command if isinstance(command, str) else " ".join(shlex.quote(arg) for arg in command) 642 643 # Create HtArgs and use htty_core.run() 644 ht_args = HtArgs( 645 command=command_str, # Use the already-formatted command string 646 subscribes=base_subscribes, 647 rows=rows, 648 cols=cols, 649 ) 650 651 # Log the exact command that would be run 652 cmd_args = ht_args.get_command() 653 process_logger.debug(f"Launching command: {' '.join(cmd_args)}") 654 655 ht_proc = htty_core_run(ht_args) 656 657 process_logger.debug(f"ht started: PID {ht_proc.pid}") 658 659 # Create a reader thread to capture ht output 660 def reader_thread( 661 ht_proc: subprocess.Popen[str], 662 queue_obj: queue.Queue[dict[str, Any]], 663 ht_process: HtWrapper, 664 thread_logger: logging.Logger, 665 ) -> None: 666 thread_logger.debug(f"Reader thread started for ht process {ht_proc.pid}") 667 668 while True: 669 if ht_proc.stdout is None: 670 thread_logger.warning(f"ht process {ht_proc.pid} stdout is None, exiting reader thread") 671 break 672 673 line = ht_proc.stdout.readline() 674 if not line: 675 thread_logger.debug(f"ht process {ht_proc.pid} stdout closed, exiting reader thread") 676 break 677 678 line = line.strip() 679 if not line: 680 continue 681 682 try: 683 event = json.loads(line) 684 thread_logger.debug(f"ht event: {event}") 685 queue_obj.put(event) 686 687 if event["type"] == "output": 688 ht_process.add_output_event(event) 689 elif event["type"] == "exitCode": 690 thread_logger.debug( 691 f"ht process {ht_proc.pid} subprocess exited with code: {event.get('data', {}).get('exitCode')}" 692 ) 693 ht_process.set_subprocess_exited(True) 694 exit_code = event.get("data", {}).get("exitCode") 695 if exit_code is not None: 696 ht_process.cmd.exit_code = exit_code 697 elif event["type"] == "pid": 698 thread_logger.debug(f"ht process {ht_proc.pid} subprocess PID: {event.get('data', {}).get('pid')}") 699 pid = event.get("data", {}).get("pid") 700 if pid is not None: 701 ht_process.cmd.pid = pid 702 elif event["type"] == "commandCompleted": 703 # Command has completed - this is the reliable signal that subprocess finished 704 ht_process.set_subprocess_completed(True) 705 elif event["type"] == "debug": 706 thread_logger.debug(f"ht process {ht_proc.pid} debug: {event.get('data', {})}") 707 # Note: We no longer rely on debug events for subprocess_completed 708 # The commandCompleted event (above) is the reliable source 709 except json.JSONDecodeError as e: 710 # Only log raw stdout when we can't parse it as JSON - this indicates an unexpected message 711 thread_logger.warning(f"ht process {ht_proc.pid} non-JSON stdout: {line} (error: {e})") 712 pass 713 714 thread_logger.debug(f"Reader thread exiting for ht process {ht_proc.pid}") 715 716 # Create an HtWrapper instance 717 process = HtWrapper( 718 ht_proc, 719 event_queue, 720 command=command_str, 721 rows=rows, 722 cols=cols, 723 no_exit=no_exit, 724 logger=process_logger, 725 ) 726 727 # Start the reader thread for stdout 728 stdout_thread = threading.Thread( 729 target=reader_thread, 730 args=(ht_proc, event_queue, process, process_logger), 731 daemon=True, 732 ) 733 stdout_thread.start() 734 735 # Start a stderr reader thread 736 def stderr_reader_thread(ht_proc: subprocess.Popen[str], thread_logger: logging.Logger) -> None: 737 thread_logger.debug(f"Stderr reader thread started for ht process {ht_proc.pid}") 738 739 while True: 740 if ht_proc.stderr is None: 741 thread_logger.warning(f"ht process {ht_proc.pid} stderr is None, exiting stderr reader thread") 742 break 743 744 line = ht_proc.stderr.readline() 745 if not line: 746 thread_logger.debug(f"ht process {ht_proc.pid} stderr closed, exiting stderr reader thread") 747 break 748 749 line = line.strip() 750 if line: 751 thread_logger.debug(f"ht stderr: {line}") 752 753 thread_logger.debug(f"Stderr reader thread exiting for ht process {ht_proc.pid}") 754 755 stderr_thread = threading.Thread(target=stderr_reader_thread, args=(ht_proc, process_logger), daemon=True) 756 stderr_thread.start() 757 758 # Wait briefly for the process to initialize and get PID 759 start_time = time.time() 760 while time.time() - start_time < 2: 761 try: 762 event = event_queue.get(block=True, timeout=0.5) 763 if event["type"] == "pid": 764 pid = event["data"]["pid"] 765 process.cmd.pid = pid 766 break 767 except queue.Empty: 768 continue 769 770 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 771 return process
As a user of the htty python library, your code will run in the python process at the root of this process tree:
python '{/path/to/your/code.py}'
└── ht
└── sh -c '{modified command}'
So if you're using htty to wrap vim, the process tree is:
python
└── ht
└── sh
└── vim
For reasons that are documented in htty-core, the command that ht runs is not:
sh -c '{command}'
Instead it's something like this:
sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/tmp/ht_fifo_5432 ; exit $exit_code'
This function invokes ht
as a subprocess such that you end up with a process tree like the one shown
above. It returns an HtWrapper
object which can be used to interact with ht and its child process.
It's up to you to clean up this process when you're done:
proc = run("some command")
# do stuff
proc.exit()
If you'd rather not risk having a bunch of ht
processes lying around and wasting CPU cycles,
consider using the terminal_session
instead.
74class HtWrapper: 75 """ 76 A wrapper around a process started with the 'ht' tool that provides 77 methods for interacting with the process and capturing its output. 78 """ 79 80 def __init__( 81 self, 82 ht_proc: "subprocess.Popen[str]", 83 event_queue: queue.Queue[dict[str, Any]], 84 command: Optional[str] = None, 85 pid: Optional[int] = None, 86 rows: Optional[int] = None, 87 cols: Optional[int] = None, 88 no_exit: bool = False, 89 logger: Optional[logging.Logger] = None, 90 ) -> None: 91 """ 92 @private 93 Users are not expect to create these directly. 94 They should use `with terminal_session(...)` or `run(...)` 95 """ 96 self._ht_proc = ht_proc # The ht process itself 97 self._cmd_process = CmdProcess(pid) 98 self._event_queue = event_queue 99 self._command = command 100 self._output_events: list[dict[str, Any]] = [] 101 self._unknown_events: list[dict[str, Any]] = [] 102 self._latest_snapshot: Optional[str] = None 103 self._start_time = time.time() 104 self._exit_code: Optional[int] = None 105 self._rows = rows 106 self._cols = cols 107 self._no_exit = no_exit 108 self._subprocess_exited = False 109 self._subprocess_completed = False # Set earlier when command completion is detected 110 111 # Use provided logger or fall back to default 112 self._logger = logger or default_logger 113 self._logger.debug(f"HTProcess created: ht_proc.pid={ht_proc.pid}, command={command}") 114 115 # Create the public interface objects 116 self.ht: ProcessController = HtProcess(ht_proc, self) 117 self.cmd: ProcessController = self._cmd_process 118 119 def __del__(self): 120 """Destructor to warn about uncleaned processes.""" 121 if hasattr(self, "_ht_proc") and self._ht_proc and self._ht_proc.poll() is None: 122 self._logger.warning( 123 f"HTProcess being garbage collected with running ht process (PID: {self._ht_proc.pid}). " 124 f"This may cause resource leaks!" 125 ) 126 # Try emergency cleanup 127 with suppress(Exception): 128 self._ht_proc.terminate() 129 130 def get_output(self) -> list[dict[str, Any]]: 131 """Return list of output events.""" 132 return [event for event in self._output_events if event.get("type") == "output"] 133 134 def add_output_event(self, event: dict[str, Any]) -> None: 135 """ 136 @private 137 Add an output event (for internal use by reader thread). 138 """ 139 self._output_events.append(event) 140 141 def set_subprocess_exited(self, exited: bool) -> None: 142 """ 143 @private 144 Set subprocess exited flag (for internal use by reader thread). 145 """ 146 self._subprocess_exited = exited 147 148 def set_subprocess_completed(self, completed: bool) -> None: 149 """ 150 @private 151 Set subprocess completed flag (for internal use by reader thread). 152 """ 153 self._subprocess_completed = completed 154 self._cmd_process.set_completed(completed) 155 156 def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None: 157 """ 158 Send keys to the terminal. 159 160 Since we use --wait-for-output, this is much more reliable than the original. 161 """ 162 key_strings = keys_to_strings(keys) 163 message = json.dumps({"type": "sendKeys", "keys": key_strings}) 164 165 self._logger.debug(f"Sending keys: {message}") 166 167 if self._ht_proc.stdin is not None: 168 try: 169 self._ht_proc.stdin.write(message + "\n") 170 self._ht_proc.stdin.flush() 171 self._logger.debug("Keys sent successfully") 172 except (BrokenPipeError, OSError) as e: 173 self._logger.error(f"Failed to send keys: {e}") 174 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 175 raise 176 else: 177 self._logger.error("ht process stdin is None") 178 179 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 180 181 def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult: 182 """ 183 Take a snapshot of the terminal output. 184 """ 185 if self._ht_proc.poll() is not None: 186 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 187 188 message = json.dumps({"type": "takeSnapshot"}) 189 self._logger.debug(f"Taking snapshot: {message}") 190 191 try: 192 if self._ht_proc.stdin is not None: 193 self._ht_proc.stdin.write(message + "\n") 194 self._ht_proc.stdin.flush() 195 self._logger.debug("Snapshot request sent successfully") 196 else: 197 raise RuntimeError("ht process stdin is not available") 198 except BrokenPipeError as e: 199 self._logger.error(f"Failed to send snapshot request: {e}") 200 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 201 raise RuntimeError( 202 f"Cannot communicate with ht process (broken pipe). " 203 f"Process may have exited. Poll result: {self._ht_proc.poll()}" 204 ) from e 205 206 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 207 208 # Process events until we find the snapshot 209 retry_count = 0 210 while retry_count < MAX_SNAPSHOT_RETRIES: 211 try: 212 event = self._event_queue.get(block=True, timeout=SNAPSHOT_RETRY_TIMEOUT) 213 except queue.Empty: 214 retry_count += 1 215 continue 216 217 if event["type"] == "snapshot": 218 data = event["data"] 219 snapshot_text = data["text"] 220 raw_seq = data["seq"] 221 222 # Convert to HTML with ANSI color support 223 html = simple_ansi_to_html(raw_seq) 224 225 return SnapshotResult( 226 text=snapshot_text, 227 html=html, 228 raw_seq=raw_seq, 229 ) 230 elif event["type"] == "output": 231 self._output_events.append(event) 232 elif event["type"] == "resize": 233 data = event.get("data", {}) 234 if "rows" in data: 235 self._rows = data["rows"] 236 if "cols" in data: 237 self._cols = data["cols"] 238 elif event["type"] == "init": 239 pass 240 else: 241 # Put non-snapshot events back in queue for reader thread to handle 242 self._event_queue.put(event) 243 244 raise RuntimeError( 245 f"Failed to receive snapshot event after {MAX_SNAPSHOT_RETRIES} attempts. " 246 f"ht process may have exited or stopped responding." 247 ) 248 249 def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int: 250 """ 251 Exit the ht process, ensuring clean shutdown. 252 253 Uses different strategies based on subprocess state: 254 - If subprocess already exited (exitCode event received): graceful shutdown via exit command 255 - If subprocess still running: forced termination with SIGTERM then SIGKILL 256 """ 257 self._logger.debug(f"Exiting HTProcess: ht_proc.pid={self._ht_proc.pid}") 258 259 # Check if we've already received the exitCode event 260 if self._subprocess_exited: 261 self._logger.debug("Subprocess already exited (exitCode event received), attempting graceful shutdown") 262 return self._graceful_exit(timeout) 263 else: 264 self._logger.debug("Subprocess has not exited yet, checking current state") 265 266 # Give a brief moment for any pending exitCode event to arrive 267 brief_wait_start = time.time() 268 while time.time() - brief_wait_start < 0.5: # Wait up to 500ms 269 if self._subprocess_exited: 270 self._logger.debug("Subprocess exited during brief wait, attempting graceful shutdown") 271 return self._graceful_exit(timeout) 272 time.sleep(0.01) 273 274 self._logger.debug("Subprocess still running after brief wait, using forced termination") 275 return self._forced_exit(timeout) 276 277 def _graceful_exit(self, timeout: float) -> int: 278 """ 279 Graceful exit: subprocess has completed, so we can send exit command to ht process. 280 """ 281 # Send exit command to ht process 282 message = json.dumps({"type": "exit"}) 283 self._logger.debug(f"Sending exit command to ht process {self._ht_proc.pid}: {message}") 284 285 try: 286 if self._ht_proc.stdin is not None: 287 self._ht_proc.stdin.write(message + "\n") 288 self._ht_proc.stdin.flush() 289 self._logger.debug(f"Exit command sent successfully to ht process {self._ht_proc.pid}") 290 self._ht_proc.stdin.close() # Close stdin after sending exit command 291 self._logger.debug(f"Closed stdin for ht process {self._ht_proc.pid}") 292 else: 293 self._logger.debug(f"ht process {self._ht_proc.pid} stdin is None, cannot send exit command") 294 except (BrokenPipeError, OSError) as e: 295 self._logger.debug( 296 f"Failed to send exit command to ht process {self._ht_proc.pid}: {e} (process may have already exited)" 297 ) 298 pass 299 300 # Wait for the ht process to finish gracefully 301 start_time = time.time() 302 while self._ht_proc.poll() is None: 303 if time.time() - start_time > timeout: 304 # Graceful exit timed out, fall back to forced termination 305 self._logger.warning( 306 f"ht process {self._ht_proc.pid} did not exit gracefully within timeout, " 307 f"falling back to forced termination" 308 ) 309 return self._forced_exit(timeout) 310 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 311 312 self._exit_code = self._ht_proc.returncode 313 if self._exit_code is None: 314 raise RuntimeError("Failed to determine ht process exit code") 315 316 self._logger.debug(f"HTProcess exited gracefully: exit_code={self._exit_code}") 317 return self._exit_code 318 319 def _forced_exit(self, timeout: float) -> int: 320 """ 321 Forced exit: subprocess may still be running, so we need to terminate everything forcefully. 322 """ 323 # Step 1: Ensure subprocess is terminated first if needed 324 if self._cmd_process.pid and not self._subprocess_exited: 325 self._logger.debug(f"Terminating subprocess: pid={self._cmd_process.pid}") 326 try: 327 os.kill(self._cmd_process.pid, 0) 328 self._cmd_process.terminate() 329 try: 330 self._cmd_process.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT) 331 self._logger.debug(f"Subprocess {self._cmd_process.pid} terminated successfully") 332 except Exception: 333 self._logger.warning(f"Subprocess {self._cmd_process.pid} did not terminate gracefully, killing") 334 with suppress(Exception): 335 self._cmd_process.kill() 336 except OSError: 337 self._logger.debug(f"Subprocess {self._cmd_process.pid} already exited") 338 pass # Process already exited 339 340 # Step 2: Force terminate the ht process with SIGTERM, then SIGKILL if needed 341 self._logger.debug(f"Force terminating ht process {self._ht_proc.pid}") 342 343 # Try SIGTERM first 344 try: 345 self._ht_proc.terminate() 346 self._logger.debug(f"Sent SIGTERM to ht process {self._ht_proc.pid}") 347 except Exception as e: 348 self._logger.debug(f"Failed to send SIGTERM to ht process {self._ht_proc.pid}: {e}") 349 350 # Wait for termination 351 start_time = time.time() 352 while self._ht_proc.poll() is None: 353 if time.time() - start_time > timeout: 354 # SIGTERM timeout, try SIGKILL 355 self._logger.warning( 356 f"ht process {self._ht_proc.pid} did not terminate with SIGTERM within timeout, sending SIGKILL" 357 ) 358 try: 359 self._ht_proc.kill() 360 self._logger.debug(f"Sent SIGKILL to ht process {self._ht_proc.pid}") 361 except Exception as e: 362 self._logger.debug(f"Failed to send SIGKILL to ht process {self._ht_proc.pid}: {e}") 363 364 # Wait for SIGKILL to take effect 365 kill_start_time = time.time() 366 while self._ht_proc.poll() is None: 367 if time.time() - kill_start_time > timeout: 368 self._logger.error(f"ht process {self._ht_proc.pid} did not respond to SIGKILL within timeout") 369 break 370 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 371 break 372 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 373 374 self._exit_code = self._ht_proc.returncode 375 if self._exit_code is None: 376 raise RuntimeError("Failed to determine ht process exit code") 377 378 self._logger.debug(f"HTProcess exited via forced termination: exit_code={self._exit_code}") 379 return self._exit_code 380 381 def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 382 """ 383 Wait for a regex pattern to appear in the terminal output. 384 385 This method efficiently waits for output by monitoring the output events from 386 the ht process rather than polling with snapshots. It checks both the current 387 terminal state (via snapshot) and any new output that arrives. 388 389 Args: 390 pattern: The regex pattern to look for in the terminal output 391 timeout: Maximum time to wait in seconds (default: 5.0) 392 393 Raises: 394 TimeoutError: If the pattern doesn't appear within the timeout period 395 RuntimeError: If the ht process has exited 396 """ 397 if self._ht_proc.poll() is not None: 398 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 399 400 self._logger.debug(f"Expecting regex pattern: '{pattern}'") 401 402 # Compile the regex pattern 403 try: 404 regex = re.compile(pattern) 405 except re.error as e: 406 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 407 408 # First check current terminal state 409 snapshot = self.snapshot() 410 if regex.search(snapshot.text): 411 self._logger.debug(f"Pattern '{pattern}' found immediately in current terminal state") 412 return 413 414 # Start time for timeout tracking 415 start_time = time.time() 416 417 # Process events until we find the pattern or timeout 418 while True: 419 # Check timeout 420 if time.time() - start_time > timeout: 421 self._logger.debug(f"Pattern '{pattern}' not found in terminal output after {timeout} seconds") 422 raise TimeoutError(f"Pattern '{pattern}' not found within {timeout} seconds") 423 424 try: 425 # Wait for next event with a short timeout to allow checking the overall timeout 426 event = self._event_queue.get(block=True, timeout=0.1) 427 except queue.Empty: 428 continue 429 430 # Process the event 431 if event["type"] == "output": 432 self._output_events.append(event) 433 # Check if pattern appears in this output 434 if "data" in event and "seq" in event["data"] and regex.search(event["data"]["seq"]): 435 self._logger.debug(f"Pattern '{pattern}' found in output event") 436 return 437 elif event["type"] == "exitCode": 438 # Put back in queue for reader thread to handle 439 self._event_queue.put(event) 440 # Don't raise here - the process might have exited after outputting what we want 441 elif event["type"] == "snapshot": 442 # If we get a snapshot event, check its content 443 if "data" in event and "text" in event["data"] and regex.search(event["data"]["text"]): 444 self._logger.debug(f"Pattern '{pattern}' found in snapshot event") 445 return 446 447 # Take a new snapshot periodically to catch any missed output 448 if len(self._output_events) % 10 == 0: # Every 10 events 449 snapshot = self.snapshot() 450 if regex.search(snapshot.text): 451 self._logger.debug(f"Pattern '{pattern}' found in periodic snapshot") 452 return 453 454 def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 455 """ 456 Wait for a regex pattern to disappear from the terminal output. 457 458 This method efficiently waits for output changes by monitoring the output events 459 from the ht process rather than polling with snapshots. It periodically checks 460 the terminal state to verify the pattern is gone. 461 462 Args: 463 pattern: The regex pattern that should disappear from the terminal output 464 timeout: Maximum time to wait in seconds (default: 5.0) 465 466 Raises: 467 TimeoutError: If the pattern doesn't disappear within the timeout period 468 RuntimeError: If the ht process has exited 469 """ 470 if self._ht_proc.poll() is not None: 471 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 472 473 self._logger.debug(f"Expecting regex pattern to disappear: '{pattern}'") 474 475 # Compile the regex pattern 476 try: 477 regex = re.compile(pattern) 478 except re.error as e: 479 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 480 481 # Start time for timeout tracking 482 start_time = time.time() 483 484 while True: 485 # Take a snapshot to check current state 486 snapshot = self.snapshot() 487 if not regex.search(snapshot.text): 488 self._logger.debug(f"Pattern '{pattern}' is now absent from terminal output") 489 return 490 491 # Check timeout 492 if time.time() - start_time > timeout: 493 self._logger.debug(f"Pattern '{pattern}' still present in terminal output after {timeout} seconds") 494 raise TimeoutError(f"Pattern '{pattern}' still present after {timeout} seconds") 495 496 # Wait for next event with a short timeout 497 try: 498 event = self._event_queue.get(block=True, timeout=0.1) 499 except queue.Empty: 500 continue 501 502 # Process the event 503 if event["type"] == "output": 504 self._output_events.append(event) 505 elif event["type"] == "exitCode": 506 # Put back in queue for reader thread to handle 507 self._event_queue.put(event) 508 # Don't raise here - the process might have exited after the pattern disappeared
A wrapper around a process started with the 'ht' tool that provides methods for interacting with the process and capturing its output.
130 def get_output(self) -> list[dict[str, Any]]: 131 """Return list of output events.""" 132 return [event for event in self._output_events if event.get("type") == "output"]
Return list of output events.
156 def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None: 157 """ 158 Send keys to the terminal. 159 160 Since we use --wait-for-output, this is much more reliable than the original. 161 """ 162 key_strings = keys_to_strings(keys) 163 message = json.dumps({"type": "sendKeys", "keys": key_strings}) 164 165 self._logger.debug(f"Sending keys: {message}") 166 167 if self._ht_proc.stdin is not None: 168 try: 169 self._ht_proc.stdin.write(message + "\n") 170 self._ht_proc.stdin.flush() 171 self._logger.debug("Keys sent successfully") 172 except (BrokenPipeError, OSError) as e: 173 self._logger.error(f"Failed to send keys: {e}") 174 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 175 raise 176 else: 177 self._logger.error("ht process stdin is None") 178 179 time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
Send keys to the terminal.
Since we use --wait-for-output, this is much more reliable than the original.
181 def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult: 182 """ 183 Take a snapshot of the terminal output. 184 """ 185 if self._ht_proc.poll() is not None: 186 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 187 188 message = json.dumps({"type": "takeSnapshot"}) 189 self._logger.debug(f"Taking snapshot: {message}") 190 191 try: 192 if self._ht_proc.stdin is not None: 193 self._ht_proc.stdin.write(message + "\n") 194 self._ht_proc.stdin.flush() 195 self._logger.debug("Snapshot request sent successfully") 196 else: 197 raise RuntimeError("ht process stdin is not available") 198 except BrokenPipeError as e: 199 self._logger.error(f"Failed to send snapshot request: {e}") 200 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 201 raise RuntimeError( 202 f"Cannot communicate with ht process (broken pipe). " 203 f"Process may have exited. Poll result: {self._ht_proc.poll()}" 204 ) from e 205 206 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 207 208 # Process events until we find the snapshot 209 retry_count = 0 210 while retry_count < MAX_SNAPSHOT_RETRIES: 211 try: 212 event = self._event_queue.get(block=True, timeout=SNAPSHOT_RETRY_TIMEOUT) 213 except queue.Empty: 214 retry_count += 1 215 continue 216 217 if event["type"] == "snapshot": 218 data = event["data"] 219 snapshot_text = data["text"] 220 raw_seq = data["seq"] 221 222 # Convert to HTML with ANSI color support 223 html = simple_ansi_to_html(raw_seq) 224 225 return SnapshotResult( 226 text=snapshot_text, 227 html=html, 228 raw_seq=raw_seq, 229 ) 230 elif event["type"] == "output": 231 self._output_events.append(event) 232 elif event["type"] == "resize": 233 data = event.get("data", {}) 234 if "rows" in data: 235 self._rows = data["rows"] 236 if "cols" in data: 237 self._cols = data["cols"] 238 elif event["type"] == "init": 239 pass 240 else: 241 # Put non-snapshot events back in queue for reader thread to handle 242 self._event_queue.put(event) 243 244 raise RuntimeError( 245 f"Failed to receive snapshot event after {MAX_SNAPSHOT_RETRIES} attempts. " 246 f"ht process may have exited or stopped responding." 247 )
Take a snapshot of the terminal output.
249 def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int: 250 """ 251 Exit the ht process, ensuring clean shutdown. 252 253 Uses different strategies based on subprocess state: 254 - If subprocess already exited (exitCode event received): graceful shutdown via exit command 255 - If subprocess still running: forced termination with SIGTERM then SIGKILL 256 """ 257 self._logger.debug(f"Exiting HTProcess: ht_proc.pid={self._ht_proc.pid}") 258 259 # Check if we've already received the exitCode event 260 if self._subprocess_exited: 261 self._logger.debug("Subprocess already exited (exitCode event received), attempting graceful shutdown") 262 return self._graceful_exit(timeout) 263 else: 264 self._logger.debug("Subprocess has not exited yet, checking current state") 265 266 # Give a brief moment for any pending exitCode event to arrive 267 brief_wait_start = time.time() 268 while time.time() - brief_wait_start < 0.5: # Wait up to 500ms 269 if self._subprocess_exited: 270 self._logger.debug("Subprocess exited during brief wait, attempting graceful shutdown") 271 return self._graceful_exit(timeout) 272 time.sleep(0.01) 273 274 self._logger.debug("Subprocess still running after brief wait, using forced termination") 275 return self._forced_exit(timeout)
Exit the ht process, ensuring clean shutdown.
Uses different strategies based on subprocess state:
- If subprocess already exited (exitCode event received): graceful shutdown via exit command
- If subprocess still running: forced termination with SIGTERM then SIGKILL
381 def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 382 """ 383 Wait for a regex pattern to appear in the terminal output. 384 385 This method efficiently waits for output by monitoring the output events from 386 the ht process rather than polling with snapshots. It checks both the current 387 terminal state (via snapshot) and any new output that arrives. 388 389 Args: 390 pattern: The regex pattern to look for in the terminal output 391 timeout: Maximum time to wait in seconds (default: 5.0) 392 393 Raises: 394 TimeoutError: If the pattern doesn't appear within the timeout period 395 RuntimeError: If the ht process has exited 396 """ 397 if self._ht_proc.poll() is not None: 398 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 399 400 self._logger.debug(f"Expecting regex pattern: '{pattern}'") 401 402 # Compile the regex pattern 403 try: 404 regex = re.compile(pattern) 405 except re.error as e: 406 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 407 408 # First check current terminal state 409 snapshot = self.snapshot() 410 if regex.search(snapshot.text): 411 self._logger.debug(f"Pattern '{pattern}' found immediately in current terminal state") 412 return 413 414 # Start time for timeout tracking 415 start_time = time.time() 416 417 # Process events until we find the pattern or timeout 418 while True: 419 # Check timeout 420 if time.time() - start_time > timeout: 421 self._logger.debug(f"Pattern '{pattern}' not found in terminal output after {timeout} seconds") 422 raise TimeoutError(f"Pattern '{pattern}' not found within {timeout} seconds") 423 424 try: 425 # Wait for next event with a short timeout to allow checking the overall timeout 426 event = self._event_queue.get(block=True, timeout=0.1) 427 except queue.Empty: 428 continue 429 430 # Process the event 431 if event["type"] == "output": 432 self._output_events.append(event) 433 # Check if pattern appears in this output 434 if "data" in event and "seq" in event["data"] and regex.search(event["data"]["seq"]): 435 self._logger.debug(f"Pattern '{pattern}' found in output event") 436 return 437 elif event["type"] == "exitCode": 438 # Put back in queue for reader thread to handle 439 self._event_queue.put(event) 440 # Don't raise here - the process might have exited after outputting what we want 441 elif event["type"] == "snapshot": 442 # If we get a snapshot event, check its content 443 if "data" in event and "text" in event["data"] and regex.search(event["data"]["text"]): 444 self._logger.debug(f"Pattern '{pattern}' found in snapshot event") 445 return 446 447 # Take a new snapshot periodically to catch any missed output 448 if len(self._output_events) % 10 == 0: # Every 10 events 449 snapshot = self.snapshot() 450 if regex.search(snapshot.text): 451 self._logger.debug(f"Pattern '{pattern}' found in periodic snapshot") 452 return
Wait for a regex pattern to appear in the terminal output.
This method efficiently waits for output by monitoring the output events from the ht process rather than polling with snapshots. It checks both the current terminal state (via snapshot) and any new output that arrives.
Args: pattern: The regex pattern to look for in the terminal output timeout: Maximum time to wait in seconds (default: 5.0)
Raises: TimeoutError: If the pattern doesn't appear within the timeout period RuntimeError: If the ht process has exited
454 def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 455 """ 456 Wait for a regex pattern to disappear from the terminal output. 457 458 This method efficiently waits for output changes by monitoring the output events 459 from the ht process rather than polling with snapshots. It periodically checks 460 the terminal state to verify the pattern is gone. 461 462 Args: 463 pattern: The regex pattern that should disappear from the terminal output 464 timeout: Maximum time to wait in seconds (default: 5.0) 465 466 Raises: 467 TimeoutError: If the pattern doesn't disappear within the timeout period 468 RuntimeError: If the ht process has exited 469 """ 470 if self._ht_proc.poll() is not None: 471 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 472 473 self._logger.debug(f"Expecting regex pattern to disappear: '{pattern}'") 474 475 # Compile the regex pattern 476 try: 477 regex = re.compile(pattern) 478 except re.error as e: 479 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 480 481 # Start time for timeout tracking 482 start_time = time.time() 483 484 while True: 485 # Take a snapshot to check current state 486 snapshot = self.snapshot() 487 if not regex.search(snapshot.text): 488 self._logger.debug(f"Pattern '{pattern}' is now absent from terminal output") 489 return 490 491 # Check timeout 492 if time.time() - start_time > timeout: 493 self._logger.debug(f"Pattern '{pattern}' still present in terminal output after {timeout} seconds") 494 raise TimeoutError(f"Pattern '{pattern}' still present after {timeout} seconds") 495 496 # Wait for next event with a short timeout 497 try: 498 event = self._event_queue.get(block=True, timeout=0.1) 499 except queue.Empty: 500 continue 501 502 # Process the event 503 if event["type"] == "output": 504 self._output_events.append(event) 505 elif event["type"] == "exitCode": 506 # Put back in queue for reader thread to handle 507 self._event_queue.put(event) 508 # Don't raise here - the process might have exited after the pattern disappeared
Wait for a regex pattern to disappear from the terminal output.
This method efficiently waits for output changes by monitoring the output events from the ht process rather than polling with snapshots. It periodically checks the terminal state to verify the pattern is gone.
Args: pattern: The regex pattern that should disappear from the terminal output timeout: Maximum time to wait in seconds (default: 5.0)
Raises: TimeoutError: If the pattern doesn't disappear within the timeout period RuntimeError: If the ht process has exited
20class ProcessController(Protocol): 21 """Protocol for process manipulation operations.""" 22 23 def exit(self, timeout: Optional[float] = None) -> int: 24 """Exit the process.""" 25 ... 26 27 def terminate(self) -> None: 28 """Terminate the process.""" 29 ... 30 31 def kill(self) -> None: 32 """Force kill the process.""" 33 ... 34 35 def wait(self, timeout: Optional[float] = None) -> Optional[int]: 36 """Wait for the process to finish.""" 37 ... 38 39 def poll(self) -> Optional[int]: 40 """Check if the process is still running.""" 41 ... 42 43 @property 44 def pid(self) -> Optional[int]: 45 """Get the process ID.""" 46 ... 47 48 @pid.setter 49 def pid(self, value: Optional[int]) -> None: 50 """Set the process ID.""" 51 ... 52 53 @property 54 def exit_code(self) -> Optional[int]: 55 """Get the exit code of the process.""" 56 ... 57 58 @exit_code.setter 59 def exit_code(self, value: Optional[int]) -> None: 60 """Set the exit code of the process.""" 61 ... 62 63 @property 64 def completed(self) -> bool: 65 """Check if the process has completed.""" 66 ...
Protocol for process manipulation operations.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
35 def wait(self, timeout: Optional[float] = None) -> Optional[int]: 36 """Wait for the process to finish.""" 37 ...
Wait for the process to finish.
62class SnapshotResult: 63 """Result of taking a terminal snapshot""" 64 65 def __init__(self, text: str, html: str, raw_seq: str): 66 self.text = text 67 self.html = html 68 self.raw_seq = raw_seq 69 70 def __repr__(self): 71 return f"SnapshotResult(text={self.text!r}, html=<{len(self.html)} chars>, raw_seq=<{len(self.raw_seq)} chars>)"
Result of taking a terminal snapshot