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're expecting large subprocess output and you want to show your user only a certain part of it. (This can be especially useful if your user is an AI and you're being charged per-token.)
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 do the following:
- run all instruction, printing snapshots along the way
- terminate the child process
- exit
If you're looking for something that doesn't clean the process up afterwards, consider one of these:
$ 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're expecting large subprocess output and you want to show your user only a certain part of it. 30(This can be especially useful if your user is an AI and you're being charged per-token.) 31 32It's a bit like a zoomed-out grep: 33Instead of finding lines of a file, it finds snapshots of a terminal session. 34 35# Library Usage 36 37The `terminal_session` context manager yields a `HtWrapper` object which has methods for communicating with the underlying `ht` process. 38 39```python 40from htty import Press, terminal_session 41 42# start an interactive bourne shell in a small headless terminal 43with terminal_session("sh -i", rows=4, cols=6) as sh: 44 45 # print enough so that the prompt is at the bottom of the screen 46 sh.send_keys([r"printf '\\n\\n\\n\\nhello world\\n'", Press.ENTER]) 47 sh.expect("world") 48 hello = sh.snapshot() 49 50 # clear the terminal 51 sh.send_keys(["clear", Press.ENTER]) 52 sh.expect_absent("world") 53 sh.expect("\\$") 54 cleared = sh.snapshot() 55 56# assert correct placement 57assert hello.text == '\\n'.join([ 58 " ", # line wrap after 6 chars 59 "hello ", 60 "world ", 61 "$ ", # four rows high 62]) 63 64# assert that clear... cleared 65assert cleared.text == '\\n'.join([ 66 "$ ", 67 " ", 68 " ", 69 " ", 70]) 71``` 72It'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. 73 74# Command Line Usage 75 76Unlike the `htty` python library, the `htty` command accepts all of its instructions before it starts. 77It will do the following: 78 791. run all instruction, printing snapshots along the way 802. terminate the child process 813. exit 82 83If you're looking for something that doesn't clean the process up afterwards, consider one of these: 84 - run [ht](https://github.com/andyk/ht?tab=readme-ov-file#usage) of `htty` 85 - use `htty` as a python library 86 - other terminal emulator libraries such as [pyte](https://github.com/selectel/pyte) 87 88``` 89$ htty --help 90Error running command: Command '['htty', '--help']' returned non-zero exit status 2. 91``` 92 93The `sl` command animates an ascii-art train engine driving from right to left across your terminal. 94Near the middle of the engine are some `I`'s an further back is a `Y`. 95`htty` can use the appearance and dissapearance of these characters to trigger snapshots of the train. 96 97The command below wraps `sl`, and captures two snapshots (triggered by Y appearing and I dissapering). 98 ints them to stdout with a '----' to indicate the end of each snapshot. 99 100``` 101$ htty -r 15 -c 50 --expect Y --snapshot --expect-absent I --snapshot -- sl 102 103 (@@@) 104 ==== ________ 105 _D _| |_______/ \\__I_I_____== 106 |(_)--- | H\\________/ | | 107 / | | H | | | | 108 | | | H |__----------------- 109 | ________|___H__/__|_____/[][]~\\____ 110 |/ | |-----------I_____I [][] [] D 111 __/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y 112 |/-=|___|= O=====O=====O=====O|_____ 113 \\_/ \\__/ \\__/ \\__/ \\__/ 114 115 116 117---- 118 119 120 ___________ 121_===__|_________| 122 =|___ ___| _________________ 123 ||_| |_|| _| \\_____A 124------| [___] | =| | 125______| | -| | 126 D |=======|____|________________________|_ 127__Y___________|__|__________________________|_ 128___/~\\___/ |_D__D__D_| |_D__D__D_| 129 \\_/ \\_/ \\_/ \\_/ \\_/ 130 131 132 133---- 134``` 135Warning: 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. 136""" 137 138import htty.keys as keys 139from htty.ht import ( 140 HtWrapper, 141 ProcessController, 142 SnapshotResult, 143 run, 144 terminal_session, 145) 146from htty.keys import Press 147from htty_core import StyleMode 148 149# [[[cog 150# import os 151# cog.out(f'__version__ = "{os.environ["HTTY_VERSION"]}"') 152# ]]] 153__version__ = "0.2.30" 154# [[[end]]] 155 156__all__ = [ 157 "terminal_session", 158 "run", 159 "HtWrapper", 160 "ProcessController", 161 "SnapshotResult", 162 "StyleMode", 163 "Press", 164 "keys", 165 "__version__", 166]
585@contextmanager 586def terminal_session( 587 command: Command, 588 rows: Rows = None, 589 cols: Cols = None, 590 logger: Logger = None, 591 extra_subscribes: ExtraSubscribes = None, 592) -> Iterator[HtWrapper]: 593 """ 594 The terminal_session context manager is a wrapper around `run` which ensures that the underlying process 595 gets cleaned up: 596 597 ```python 598 with terminal_session("some command") as proc: 599 # interact with the running command here 600 assert proc.exit_code is not None 601 s = proc.snapshot() 602 603 # htty terminates your command on context exit 604 assert proc.exit_code is None 605 assert "hello world" in proc.snapshot() 606 ``` 607 608 Its usage is otherwise the same as `run`. 609 Like `run` it returns a `HtWrapper`. 610 611 """ 612 613 proc = run( 614 command, 615 rows=rows, 616 cols=cols, 617 no_exit=True, 618 logger=logger, 619 extra_subscribes=extra_subscribes, 620 ) 621 try: 622 yield proc 623 finally: 624 try: 625 if proc.cmd.pid: 626 proc.cmd.terminate() 627 proc.cmd.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT) 628 except Exception: 629 try: 630 if proc.cmd.pid: 631 proc.cmd.kill() 632 except Exception: 633 pass 634 635 try: 636 proc.ht.terminate() 637 proc.ht.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT) 638 except Exception: 639 with suppress(Exception): 640 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.
Like run it returns a HtWrapper.
643def run( 644 command: Command, 645 rows: Rows = None, 646 cols: Cols = None, 647 no_exit: NoExit = True, 648 logger: Logger = None, 649 extra_subscribes: ExtraSubscribes = None, 650 style_mode: Optional[StyleMode] = None, 651) -> HtWrapper: 652 """ 653 As a user of the htty python library, your code will run in the python process at the root of this 654 process tree: 655 656 python '{/path/to/your/code.py}' 657 └── ht 658 └── sh -c '{modified command}' 659 660 So if you're using htty to wrap vim, the process tree is: 661 662 ``` 663 python 664 └── ht 665 └── sh 666 └── vim 667 ``` 668 669 This function invokes `ht` as a subprocess such that you end up with a process tree like the one shown 670 above. It returns an `HtWrapper` object which can be used to interact with ht and its child process. 671 672 It's up to you to clean up this process when you're done: 673 674 ```python 675 proc = run("some command") 676 # do stuff 677 proc.exit() 678 ``` 679 680 If you'd rather not risk having a bunch of `ht` processes lying around and wasting CPU cycles, 681 consider using the `terminal_session` instead. 682 683 For reasons that are documented in 684 [htty-core](https://matrixmanatyrservice.github.io/htty/htty-core/htty_core.html#HtEvent.COMMAND_COMPLETED), the 685 command that ht runs is not: 686 687 sh -c '{command}' 688 689 Instead it's something like this: 690 691 sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/tmp/ht_fifo_5432 ; exit $exit_code' 692 693 Because of this, it's possible to come up with command strings that cause sh to behave in problematic ways (for 694 example: `'`). For now the mitigation for this is: "don't do that." (If you'd like me to prioritize changing this 695 please leave a comment in https://github.com/MatrixManAtYrService/htty/issues/2) 696 """ 697 # Use provided logger or fall back to default 698 process_logger = logger or default_logger 699 700 # Create a queue for events 701 event_queue: queue.Queue[dict[str, Any]] = queue.Queue() 702 703 # Build the ht subscription list 704 base_subscribes = [ 705 HtEvent.INIT, 706 HtEvent.SNAPSHOT, 707 HtEvent.OUTPUT, 708 HtEvent.RESIZE, 709 HtEvent.PID, 710 HtEvent.EXIT_CODE, 711 HtEvent.COMMAND_COMPLETED, 712 ] 713 if extra_subscribes: 714 # Convert string subscribes to HtEvent enum values 715 for sub in extra_subscribes: 716 try: 717 base_subscribes.append(HtEvent(sub)) 718 except ValueError: 719 process_logger.warning(f"Unknown subscription event: {sub}") 720 721 # Convert command to string if it's a list, properly escaping shell arguments 722 command_str = command if isinstance(command, str) else " ".join(shlex.quote(arg) for arg in command) 723 724 # Create HtArgs and use htty_core.run() 725 ht_args = HtArgs( 726 command=command_str, # Use the already-formatted command string 727 subscribes=base_subscribes, 728 rows=rows, 729 cols=cols, 730 style_mode=style_mode, 731 ) 732 733 # Log the exact command that would be run 734 cmd_args = ht_args.get_command() 735 process_logger.debug(f"Launching command: {' '.join(cmd_args)}") 736 737 ht_proc = htty_core_run(ht_args) 738 739 process_logger.debug(f"ht started: PID {ht_proc.pid}") 740 741 # Create a reader thread to capture ht output 742 def reader_thread( 743 ht_proc: subprocess.Popen[str], 744 queue_obj: queue.Queue[dict[str, Any]], 745 ht_process: HtWrapper, 746 thread_logger: logging.Logger, 747 ) -> None: 748 thread_logger.debug(f"Reader thread started for ht process {ht_proc.pid}") 749 750 while True: 751 if ht_proc.stdout is None: 752 thread_logger.warning(f"ht process {ht_proc.pid} stdout is None, exiting reader thread") 753 break 754 755 line = ht_proc.stdout.readline() 756 if not line: 757 thread_logger.debug(f"ht process {ht_proc.pid} stdout closed, exiting reader thread") 758 break 759 760 line = line.strip() 761 if not line: 762 continue 763 764 try: 765 event = json.loads(line) 766 thread_logger.debug(f"ht event: {event}") 767 queue_obj.put(event) 768 769 if event["type"] == "output": 770 ht_process.add_output_event(event) 771 elif event["type"] == "exitCode": 772 thread_logger.debug( 773 f"ht process {ht_proc.pid} subprocess exited with code: {event.get('data', {}).get('exitCode')}" 774 ) 775 ht_process.set_subprocess_exited(True) 776 exit_code = event.get("data", {}).get("exitCode") 777 if exit_code is not None: 778 ht_process.cmd.exit_code = exit_code 779 elif event["type"] == "pid": 780 thread_logger.debug(f"ht process {ht_proc.pid} subprocess PID: {event.get('data', {}).get('pid')}") 781 pid = event.get("data", {}).get("pid") 782 if pid is not None: 783 ht_process.cmd.pid = pid 784 elif event["type"] == "commandCompleted": 785 # Command has completed - this is the reliable signal that subprocess finished 786 ht_process.set_subprocess_completed(True) 787 elif event["type"] == "debug": 788 thread_logger.debug(f"ht process {ht_proc.pid} debug: {event.get('data', {})}") 789 # Note: We no longer rely on debug events for subprocess_completed 790 # The commandCompleted event (above) is the reliable source 791 except json.JSONDecodeError as e: 792 # Only log raw stdout when we can't parse it as JSON - this indicates an unexpected message 793 thread_logger.warning(f"ht process {ht_proc.pid} non-JSON stdout: {line} (error: {e})") 794 pass 795 796 thread_logger.debug(f"Reader thread exiting for ht process {ht_proc.pid}") 797 798 # Create an HtWrapper instance 799 process = HtWrapper( 800 ht_proc, 801 event_queue, 802 command=command_str, 803 rows=rows, 804 cols=cols, 805 no_exit=no_exit, 806 logger=process_logger, 807 ) 808 809 # Start the reader thread for stdout 810 stdout_thread = threading.Thread( 811 target=reader_thread, 812 args=(ht_proc, event_queue, process, process_logger), 813 daemon=True, 814 ) 815 stdout_thread.start() 816 817 # Start a stderr reader thread 818 def stderr_reader_thread(ht_proc: subprocess.Popen[str], thread_logger: logging.Logger) -> None: 819 thread_logger.debug(f"Stderr reader thread started for ht process {ht_proc.pid}") 820 821 while True: 822 if ht_proc.stderr is None: 823 thread_logger.warning(f"ht process {ht_proc.pid} stderr is None, exiting stderr reader thread") 824 break 825 826 line = ht_proc.stderr.readline() 827 if not line: 828 thread_logger.debug(f"ht process {ht_proc.pid} stderr closed, exiting stderr reader thread") 829 break 830 831 line = line.strip() 832 if line: 833 thread_logger.debug(f"ht stderr: {line}") 834 835 thread_logger.debug(f"Stderr reader thread exiting for ht process {ht_proc.pid}") 836 837 stderr_thread = threading.Thread(target=stderr_reader_thread, args=(ht_proc, process_logger), daemon=True) 838 stderr_thread.start() 839 840 # Wait briefly for the process to initialize and get PID 841 start_time = time.time() 842 while time.time() - start_time < 2: 843 try: 844 event = event_queue.get(block=True, timeout=0.5) 845 if event["type"] == "pid": 846 pid = event["data"]["pid"] 847 process.cmd.pid = pid 848 break 849 except queue.Empty: 850 continue 851 852 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 853 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
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.
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'
Because of this, it's possible to come up with command strings that cause sh to behave in problematic ways (for
example: '). For now the mitigation for this is: "don't do that." (If you'd like me to prioritize changing this
please leave a comment in https://github.com/MatrixManAtYrService/htty/issues/2)
81class HtWrapper: 82 """ 83 A wrapper around a process started with the 'ht' tool that provides 84 methods for interacting with the process and capturing its output. 85 """ 86 87 ht: ProcessController 88 """ 89 Helpers for interacting with the `ht` process (a child process of the python 90 that called `htty.run` or `htty.terminal_session`) 91 """ 92 93 cmd: ProcessController 94 """ 95 Helpers for interacting with the shell that wraps the caller's command (`ht`'s child process) 96 """ 97 98 def __init__( 99 self, 100 ht_proc: "subprocess.Popen[str]", 101 event_queue: queue.Queue[dict[str, Any]], 102 command: Optional[str] = None, 103 pid: Optional[int] = None, 104 rows: Optional[int] = None, 105 cols: Optional[int] = None, 106 no_exit: bool = False, 107 logger: Optional[logging.Logger] = None, 108 ) -> None: 109 """ 110 @private 111 Users are not expect to create these directly. 112 They should use `with terminal_session(...)` or `run(...)` 113 """ 114 self._ht_proc = ht_proc # The ht process itself 115 self._cmd_process = CmdProcess(pid) 116 self._event_queue = event_queue 117 self._command = command 118 self._output_events: list[dict[str, Any]] = [] 119 self._unknown_events: list[dict[str, Any]] = [] 120 self._latest_snapshot: Optional[str] = None 121 self._start_time = time.time() 122 self._exit_code: Optional[int] = None 123 self._rows = rows 124 self._cols = cols 125 self._no_exit = no_exit 126 self._subprocess_exited = False 127 self._subprocess_completed = False # Set earlier when command completion is detected 128 129 # Use provided logger or fall back to default 130 self._logger = logger or default_logger 131 self._logger.debug(f"HTProcess created: ht_proc.pid={ht_proc.pid}, command={command}") 132 133 # Create the public interface objects 134 self.ht: ProcessController = HtProcess(ht_proc, self) 135 self.cmd: ProcessController = self._cmd_process 136 137 def __del__(self): 138 """Destructor to warn about uncleaned processes.""" 139 if hasattr(self, "_ht_proc") and self._ht_proc and self._ht_proc.poll() is None: 140 self._logger.warning( 141 f"HTProcess being garbage collected with running ht process (PID: {self._ht_proc.pid}). " 142 f"This may cause resource leaks!" 143 ) 144 # Try emergency cleanup 145 with suppress(Exception): 146 self._ht_proc.terminate() 147 148 def get_output(self) -> list[dict[str, Any]]: 149 """ 150 Return list of [output](./htty-core/htty_core.html#HtEvent.OUTPUT) events.""" 151 return [event for event in self._output_events if event.get("type") == "output"] 152 153 def add_output_event(self, event: dict[str, Any]) -> None: 154 """ 155 @private 156 Add an output event (for internal use by reader thread). 157 """ 158 self._output_events.append(event) 159 160 def set_subprocess_exited(self, exited: bool) -> None: 161 """ 162 @private 163 Set subprocess exited flag (for internal use by reader thread). 164 """ 165 self._subprocess_exited = exited 166 167 def set_subprocess_completed(self, completed: bool) -> None: 168 """ 169 @private 170 Set subprocess completed flag (for internal use by reader thread). 171 """ 172 self._subprocess_completed = completed 173 self._cmd_process.set_completed(completed) 174 175 def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None: 176 """ 177 Send keys to the terminal. Accepts strings, `Press` objects, and lists of strings or `Press` objects. 178 For keys that you can `Press`, see 179 [keys.py](https://github.com/MatrixManAtYrService/htty/blob/main/htty/src/htty/keys.py). 180 181 ```python 182 from htty import Press, terminal_session 183 184 with ( 185 terminal_session("sh -i", rows=4, cols=40, logger=test_logger) as sh, 186 ): 187 sh.send_keys("echo foo && sleep 999") 188 sh.send_keys(Press.ENTER) 189 sh.expect("^foo") 190 sh.send_keys(Press.CTRL_Z) 191 sh.expect("Stopped") 192 sh.send_keys(["clear", Press.ENTER]) 193 ``` 194 195 These are sent to `ht` as events that look like this 196 197 ```json 198 {"type": "sendKeys", "keys": ["echo foo && sleep 999", "Enter"]} 199 ``` 200 201 Notice that Press.ENTER is still sent as "Enter" under the hood. 202 `ht` checks to see if it corresponds with a known key and sends it letter-at-a-time if not. 203 204 Because of this, you might run into suprises if you want to type individual characters which happen to spell out 205 a known key such as "Enter" or "Backspace". 206 207 Work around this by breaking up the key names like so: 208 209 ```python 210 sh.send_keys(["Ente", "r"]) 211 ``` 212 213 If this behavior is problematic for you, consider submitting an issue. 214 """ 215 key_strings = keys_to_strings(keys) 216 message = json.dumps({"type": "sendKeys", "keys": key_strings}) 217 218 self._logger.debug(f"Sending keys: {message}") 219 220 if self._ht_proc.stdin is not None: 221 try: 222 self._ht_proc.stdin.write(message + "\n") 223 self._ht_proc.stdin.flush() 224 self._logger.debug("Keys sent successfully") 225 except (BrokenPipeError, OSError) as e: 226 self._logger.error(f"Failed to send keys: {e}") 227 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 228 raise 229 else: 230 self._logger.error("ht process stdin is None") 231 232 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 233 234 def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult: 235 """ 236 Take a snapshot of the terminal output. 237 """ 238 if self._ht_proc.poll() is not None: 239 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 240 241 message = json.dumps({"type": "takeSnapshot"}) 242 self._logger.debug(f"Taking snapshot: {message}") 243 244 try: 245 if self._ht_proc.stdin is not None: 246 self._ht_proc.stdin.write(message + "\n") 247 self._ht_proc.stdin.flush() 248 self._logger.debug("Snapshot request sent successfully") 249 else: 250 raise RuntimeError("ht process stdin is not available") 251 except BrokenPipeError as e: 252 self._logger.error(f"Failed to send snapshot request: {e}") 253 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 254 raise RuntimeError( 255 f"Cannot communicate with ht process (broken pipe). " 256 f"Process may have exited. Poll result: {self._ht_proc.poll()}" 257 ) from e 258 259 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 260 261 # Use tenacity to retry getting the snapshot 262 return self._wait_for_snapshot(timeout) 263 264 def _wait_for_snapshot(self, timeout: float) -> SnapshotResult: 265 """ 266 Wait for snapshot response from the event queue with timeout and retries. 267 """ 268 269 @retry( 270 stop=stop_after_delay(timeout), 271 wait=wait_fixed(SNAPSHOT_RETRY_TIMEOUT), 272 retry=retry_if_exception_type(SnapshotNotReady), 273 ) 274 def _get_snapshot() -> SnapshotResult: 275 try: 276 event = self._event_queue.get(block=True, timeout=SNAPSHOT_RETRY_TIMEOUT) 277 except queue.Empty as e: 278 raise SnapshotNotReady("No events available in queue") from e 279 280 if event["type"] == "snapshot": 281 data = event["data"] 282 snapshot_text = data["text"] 283 raw_seq = data["seq"] 284 285 # Convert to HTML with ANSI color support 286 html = simple_ansi_to_html(raw_seq) 287 288 return SnapshotResult( 289 text=snapshot_text, 290 html=html, 291 raw_seq=raw_seq, 292 ) 293 elif event["type"] == "output": 294 self._output_events.append(event) 295 elif event["type"] == "resize": 296 data = event.get("data", {}) 297 if "rows" in data: 298 self._rows = data["rows"] 299 if "cols" in data: 300 self._cols = data["cols"] 301 elif event["type"] == "init": 302 pass 303 else: 304 # Put non-snapshot events back in queue for reader thread to handle 305 self._event_queue.put(event) 306 307 # If we get here, we didn't find a snapshot, so retry 308 raise SnapshotNotReady(f"Received {event['type']} event, waiting for snapshot") 309 310 try: 311 return _get_snapshot() 312 except Exception as e: 313 # Handle both direct SnapshotNotReady and tenacity RetryError 314 from tenacity import RetryError 315 316 if isinstance(e, (SnapshotNotReady, RetryError)): 317 raise RuntimeError( 318 f"Failed to receive snapshot event within {timeout} seconds. " 319 f"ht process may have exited or stopped responding." 320 ) from e 321 raise 322 323 def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int: 324 """ 325 Exit the ht process, ensuring clean shutdown. 326 327 Uses different strategies based on subprocess state: 328 - If subprocess already exited (exitCode event received): graceful shutdown via exit command 329 - If subprocess still running: forced termination with SIGTERM then SIGKILL 330 """ 331 self._logger.debug(f"Exiting HTProcess: ht_proc.pid={self._ht_proc.pid}") 332 333 # Check if we've already received the exitCode event 334 if self._subprocess_exited: 335 self._logger.debug("Subprocess already exited (exitCode event received), attempting graceful shutdown") 336 return self._graceful_exit(timeout) 337 else: 338 self._logger.debug("Subprocess has not exited yet, checking current state") 339 340 # Give a brief moment for any pending exitCode event to arrive 341 brief_wait_start = time.time() 342 while time.time() - brief_wait_start < 0.5: # Wait up to 500ms 343 if self._subprocess_exited: 344 self._logger.debug("Subprocess exited during brief wait, attempting graceful shutdown") 345 return self._graceful_exit(timeout) 346 time.sleep(0.01) 347 348 self._logger.debug("Subprocess still running after brief wait, using forced termination") 349 return self._forced_exit(timeout) 350 351 def _graceful_exit(self, timeout: float) -> int: 352 """ 353 Graceful exit: subprocess has completed, so we can send exit command to ht process. 354 """ 355 # Send exit command to ht process 356 message = json.dumps({"type": "exit"}) 357 self._logger.debug(f"Sending exit command to ht process {self._ht_proc.pid}: {message}") 358 359 try: 360 if self._ht_proc.stdin is not None: 361 self._ht_proc.stdin.write(message + "\n") 362 self._ht_proc.stdin.flush() 363 self._logger.debug(f"Exit command sent successfully to ht process {self._ht_proc.pid}") 364 self._ht_proc.stdin.close() # Close stdin after sending exit command 365 self._logger.debug(f"Closed stdin for ht process {self._ht_proc.pid}") 366 else: 367 self._logger.debug(f"ht process {self._ht_proc.pid} stdin is None, cannot send exit command") 368 except (BrokenPipeError, OSError) as e: 369 self._logger.debug( 370 f"Failed to send exit command to ht process {self._ht_proc.pid}: {e} (process may have already exited)" 371 ) 372 pass 373 374 # Wait for the ht process to finish gracefully 375 start_time = time.time() 376 while self._ht_proc.poll() is None: 377 if time.time() - start_time > timeout: 378 # Graceful exit timed out, fall back to forced termination 379 self._logger.warning( 380 f"ht process {self._ht_proc.pid} did not exit gracefully within timeout, " 381 f"falling back to forced termination" 382 ) 383 return self._forced_exit(timeout) 384 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 385 386 self._exit_code = self._ht_proc.returncode 387 if self._exit_code is None: 388 raise RuntimeError("Failed to determine ht process exit code") 389 390 self._logger.debug(f"HTProcess exited gracefully: exit_code={self._exit_code}") 391 return self._exit_code 392 393 def _forced_exit(self, timeout: float) -> int: 394 """ 395 Forced exit: subprocess may still be running, so we need to terminate everything forcefully. 396 """ 397 # Step 1: Ensure subprocess is terminated first if needed 398 if self._cmd_process.pid and not self._subprocess_exited: 399 self._logger.debug(f"Terminating subprocess: pid={self._cmd_process.pid}") 400 try: 401 os.kill(self._cmd_process.pid, 0) 402 self._cmd_process.terminate() 403 try: 404 self._cmd_process.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT) 405 self._logger.debug(f"Subprocess {self._cmd_process.pid} terminated successfully") 406 except Exception: 407 self._logger.warning(f"Subprocess {self._cmd_process.pid} did not terminate gracefully, killing") 408 with suppress(Exception): 409 self._cmd_process.kill() 410 except OSError: 411 self._logger.debug(f"Subprocess {self._cmd_process.pid} already exited") 412 pass # Process already exited 413 414 # Step 2: Force terminate the ht process with SIGTERM, then SIGKILL if needed 415 self._logger.debug(f"Force terminating ht process {self._ht_proc.pid}") 416 417 # Try SIGTERM first 418 try: 419 self._ht_proc.terminate() 420 self._logger.debug(f"Sent SIGTERM to ht process {self._ht_proc.pid}") 421 except Exception as e: 422 self._logger.debug(f"Failed to send SIGTERM to ht process {self._ht_proc.pid}: {e}") 423 424 # Wait for termination 425 start_time = time.time() 426 while self._ht_proc.poll() is None: 427 if time.time() - start_time > timeout: 428 # SIGTERM timeout, try SIGKILL 429 self._logger.warning( 430 f"ht process {self._ht_proc.pid} did not terminate with SIGTERM within timeout, sending SIGKILL" 431 ) 432 try: 433 self._ht_proc.kill() 434 self._logger.debug(f"Sent SIGKILL to ht process {self._ht_proc.pid}") 435 except Exception as e: 436 self._logger.debug(f"Failed to send SIGKILL to ht process {self._ht_proc.pid}: {e}") 437 438 # Wait for SIGKILL to take effect 439 kill_start_time = time.time() 440 while self._ht_proc.poll() is None: 441 if time.time() - kill_start_time > timeout: 442 self._logger.error(f"ht process {self._ht_proc.pid} did not respond to SIGKILL within timeout") 443 break 444 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 445 break 446 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 447 448 self._exit_code = self._ht_proc.returncode 449 if self._exit_code is None: 450 raise RuntimeError("Failed to determine ht process exit code") 451 452 self._logger.debug(f"HTProcess exited via forced termination: exit_code={self._exit_code}") 453 return self._exit_code 454 455 def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 456 """ 457 Wait for a regex pattern to appear in the terminal output. 458 459 This method efficiently waits for output by monitoring the output events from 460 the ht process rather than polling with snapshots. It checks both the current 461 terminal state (via snapshot) and any new output that arrives. 462 463 Args: 464 pattern: The regex pattern to look for in the terminal output 465 timeout: Maximum time to wait in seconds (default: 5.0) 466 467 Raises: 468 TimeoutError: If the pattern doesn't appear within the timeout period 469 RuntimeError: If the ht process has exited 470 """ 471 if self._ht_proc.poll() is not None: 472 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 473 474 self._logger.debug(f"Expecting regex pattern: '{pattern}'") 475 476 # Compile the regex pattern 477 try: 478 regex = re.compile(pattern, re.MULTILINE) 479 except re.error as e: 480 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 481 482 # First check current terminal state 483 snapshot = self.snapshot() 484 if regex.search(snapshot.text): 485 self._logger.debug(f"Pattern '{pattern}' found immediately in current terminal state") 486 return 487 488 # Start time for timeout tracking 489 start_time = time.time() 490 491 # Process events until we find the pattern or timeout 492 while True: 493 # Check timeout 494 if time.time() - start_time > timeout: 495 self._logger.debug(f"Pattern '{pattern}' not found in terminal output after {timeout} seconds") 496 raise TimeoutError(f"Pattern '{pattern}' not found within {timeout} seconds") 497 498 try: 499 # Wait for next event with a short timeout to allow checking the overall timeout 500 event = self._event_queue.get(block=True, timeout=0.1) 501 except queue.Empty: 502 continue 503 504 # Process the event 505 if event["type"] == "output": 506 self._output_events.append(event) 507 # Check if pattern appears in this output 508 if "data" in event and "seq" in event["data"] and regex.search(event["data"]["seq"]): 509 self._logger.debug(f"Pattern '{pattern}' found in output event") 510 return 511 elif event["type"] == "exitCode": 512 # Put back in queue for reader thread to handle 513 self._event_queue.put(event) 514 # Don't raise here - the process might have exited after outputting what we want 515 elif event["type"] == "snapshot": 516 # If we get a snapshot event, check its content 517 if "data" in event and "text" in event["data"] and regex.search(event["data"]["text"]): 518 self._logger.debug(f"Pattern '{pattern}' found in snapshot event") 519 return 520 521 # Take a new snapshot periodically to catch any missed output 522 if len(self._output_events) % 10 == 0: # Every 10 events 523 snapshot = self.snapshot() 524 if regex.search(snapshot.text): 525 self._logger.debug(f"Pattern '{pattern}' found in periodic snapshot") 526 return 527 528 def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 529 """ 530 Wait for a regex pattern to disappear from the terminal output. 531 532 This method efficiently waits for output changes by monitoring the output events 533 from the ht process rather than polling with snapshots. It periodically checks 534 the terminal state to verify the pattern is gone. 535 536 Args: 537 pattern: The regex pattern that should disappear from the terminal output 538 timeout: Maximum time to wait in seconds (default: 5.0) 539 540 Raises: 541 TimeoutError: If the pattern doesn't disappear within the timeout period 542 RuntimeError: If the ht process has exited 543 """ 544 if self._ht_proc.poll() is not None: 545 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 546 547 self._logger.debug(f"Expecting regex pattern to disappear: '{pattern}'") 548 549 # Compile the regex pattern 550 try: 551 regex = re.compile(pattern, re.MULTILINE) 552 except re.error as e: 553 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 554 555 # Start time for timeout tracking 556 start_time = time.time() 557 558 while True: 559 # Take a snapshot to check current state 560 snapshot = self.snapshot() 561 if not regex.search(snapshot.text): 562 self._logger.debug(f"Pattern '{pattern}' is now absent from terminal output") 563 return 564 565 # Check timeout 566 if time.time() - start_time > timeout: 567 self._logger.debug(f"Pattern '{pattern}' still present in terminal output after {timeout} seconds") 568 raise TimeoutError(f"Pattern '{pattern}' still present after {timeout} seconds") 569 570 # Wait for next event with a short timeout 571 try: 572 event = self._event_queue.get(block=True, timeout=0.1) 573 except queue.Empty: 574 continue 575 576 # Process the event 577 if event["type"] == "output": 578 self._output_events.append(event) 579 elif event["type"] == "exitCode": 580 # Put back in queue for reader thread to handle 581 self._event_queue.put(event) 582 # 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.
Helpers for interacting with the ht process (a child process of the python
that called htty.run or htty.terminal_session)
Helpers for interacting with the shell that wraps the caller's command (ht's child process)
148 def get_output(self) -> list[dict[str, Any]]: 149 """ 150 Return list of [output](./htty-core/htty_core.html#HtEvent.OUTPUT) events.""" 151 return [event for event in self._output_events if event.get("type") == "output"]
Return list of output events.
175 def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None: 176 """ 177 Send keys to the terminal. Accepts strings, `Press` objects, and lists of strings or `Press` objects. 178 For keys that you can `Press`, see 179 [keys.py](https://github.com/MatrixManAtYrService/htty/blob/main/htty/src/htty/keys.py). 180 181 ```python 182 from htty import Press, terminal_session 183 184 with ( 185 terminal_session("sh -i", rows=4, cols=40, logger=test_logger) as sh, 186 ): 187 sh.send_keys("echo foo && sleep 999") 188 sh.send_keys(Press.ENTER) 189 sh.expect("^foo") 190 sh.send_keys(Press.CTRL_Z) 191 sh.expect("Stopped") 192 sh.send_keys(["clear", Press.ENTER]) 193 ``` 194 195 These are sent to `ht` as events that look like this 196 197 ```json 198 {"type": "sendKeys", "keys": ["echo foo && sleep 999", "Enter"]} 199 ``` 200 201 Notice that Press.ENTER is still sent as "Enter" under the hood. 202 `ht` checks to see if it corresponds with a known key and sends it letter-at-a-time if not. 203 204 Because of this, you might run into suprises if you want to type individual characters which happen to spell out 205 a known key such as "Enter" or "Backspace". 206 207 Work around this by breaking up the key names like so: 208 209 ```python 210 sh.send_keys(["Ente", "r"]) 211 ``` 212 213 If this behavior is problematic for you, consider submitting an issue. 214 """ 215 key_strings = keys_to_strings(keys) 216 message = json.dumps({"type": "sendKeys", "keys": key_strings}) 217 218 self._logger.debug(f"Sending keys: {message}") 219 220 if self._ht_proc.stdin is not None: 221 try: 222 self._ht_proc.stdin.write(message + "\n") 223 self._ht_proc.stdin.flush() 224 self._logger.debug("Keys sent successfully") 225 except (BrokenPipeError, OSError) as e: 226 self._logger.error(f"Failed to send keys: {e}") 227 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 228 raise 229 else: 230 self._logger.error("ht process stdin is None") 231 232 time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
Send keys to the terminal. Accepts strings, Press objects, and lists of strings or Press objects.
For keys that you can Press, see
keys.py.
from htty import Press, terminal_session
with (
terminal_session("sh -i", rows=4, cols=40, logger=test_logger) as sh,
):
sh.send_keys("echo foo && sleep 999")
sh.send_keys(Press.ENTER)
sh.expect("^foo")
sh.send_keys(Press.CTRL_Z)
sh.expect("Stopped")
sh.send_keys(["clear", Press.ENTER])
These are sent to ht as events that look like this
{"type": "sendKeys", "keys": ["echo foo && sleep 999", "Enter"]}
Notice that Press.ENTER is still sent as "Enter" under the hood.
ht checks to see if it corresponds with a known key and sends it letter-at-a-time if not.
Because of this, you might run into suprises if you want to type individual characters which happen to spell out a known key such as "Enter" or "Backspace".
Work around this by breaking up the key names like so:
sh.send_keys(["Ente", "r"])
If this behavior is problematic for you, consider submitting an issue.
234 def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult: 235 """ 236 Take a snapshot of the terminal output. 237 """ 238 if self._ht_proc.poll() is not None: 239 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 240 241 message = json.dumps({"type": "takeSnapshot"}) 242 self._logger.debug(f"Taking snapshot: {message}") 243 244 try: 245 if self._ht_proc.stdin is not None: 246 self._ht_proc.stdin.write(message + "\n") 247 self._ht_proc.stdin.flush() 248 self._logger.debug("Snapshot request sent successfully") 249 else: 250 raise RuntimeError("ht process stdin is not available") 251 except BrokenPipeError as e: 252 self._logger.error(f"Failed to send snapshot request: {e}") 253 self._logger.error(f"ht process poll result: {self._ht_proc.poll()}") 254 raise RuntimeError( 255 f"Cannot communicate with ht process (broken pipe). " 256 f"Process may have exited. Poll result: {self._ht_proc.poll()}" 257 ) from e 258 259 time.sleep(DEFAULT_SLEEP_AFTER_KEYS) 260 261 # Use tenacity to retry getting the snapshot 262 return self._wait_for_snapshot(timeout)
Take a snapshot of the terminal output.
323 def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int: 324 """ 325 Exit the ht process, ensuring clean shutdown. 326 327 Uses different strategies based on subprocess state: 328 - If subprocess already exited (exitCode event received): graceful shutdown via exit command 329 - If subprocess still running: forced termination with SIGTERM then SIGKILL 330 """ 331 self._logger.debug(f"Exiting HTProcess: ht_proc.pid={self._ht_proc.pid}") 332 333 # Check if we've already received the exitCode event 334 if self._subprocess_exited: 335 self._logger.debug("Subprocess already exited (exitCode event received), attempting graceful shutdown") 336 return self._graceful_exit(timeout) 337 else: 338 self._logger.debug("Subprocess has not exited yet, checking current state") 339 340 # Give a brief moment for any pending exitCode event to arrive 341 brief_wait_start = time.time() 342 while time.time() - brief_wait_start < 0.5: # Wait up to 500ms 343 if self._subprocess_exited: 344 self._logger.debug("Subprocess exited during brief wait, attempting graceful shutdown") 345 return self._graceful_exit(timeout) 346 time.sleep(0.01) 347 348 self._logger.debug("Subprocess still running after brief wait, using forced termination") 349 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
455 def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 456 """ 457 Wait for a regex pattern to appear in the terminal output. 458 459 This method efficiently waits for output by monitoring the output events from 460 the ht process rather than polling with snapshots. It checks both the current 461 terminal state (via snapshot) and any new output that arrives. 462 463 Args: 464 pattern: The regex pattern to look for in the terminal output 465 timeout: Maximum time to wait in seconds (default: 5.0) 466 467 Raises: 468 TimeoutError: If the pattern doesn't appear within the timeout period 469 RuntimeError: If the ht process has exited 470 """ 471 if self._ht_proc.poll() is not None: 472 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 473 474 self._logger.debug(f"Expecting regex pattern: '{pattern}'") 475 476 # Compile the regex pattern 477 try: 478 regex = re.compile(pattern, re.MULTILINE) 479 except re.error as e: 480 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 481 482 # First check current terminal state 483 snapshot = self.snapshot() 484 if regex.search(snapshot.text): 485 self._logger.debug(f"Pattern '{pattern}' found immediately in current terminal state") 486 return 487 488 # Start time for timeout tracking 489 start_time = time.time() 490 491 # Process events until we find the pattern or timeout 492 while True: 493 # Check timeout 494 if time.time() - start_time > timeout: 495 self._logger.debug(f"Pattern '{pattern}' not found in terminal output after {timeout} seconds") 496 raise TimeoutError(f"Pattern '{pattern}' not found within {timeout} seconds") 497 498 try: 499 # Wait for next event with a short timeout to allow checking the overall timeout 500 event = self._event_queue.get(block=True, timeout=0.1) 501 except queue.Empty: 502 continue 503 504 # Process the event 505 if event["type"] == "output": 506 self._output_events.append(event) 507 # Check if pattern appears in this output 508 if "data" in event and "seq" in event["data"] and regex.search(event["data"]["seq"]): 509 self._logger.debug(f"Pattern '{pattern}' found in output event") 510 return 511 elif event["type"] == "exitCode": 512 # Put back in queue for reader thread to handle 513 self._event_queue.put(event) 514 # Don't raise here - the process might have exited after outputting what we want 515 elif event["type"] == "snapshot": 516 # If we get a snapshot event, check its content 517 if "data" in event and "text" in event["data"] and regex.search(event["data"]["text"]): 518 self._logger.debug(f"Pattern '{pattern}' found in snapshot event") 519 return 520 521 # Take a new snapshot periodically to catch any missed output 522 if len(self._output_events) % 10 == 0: # Every 10 events 523 snapshot = self.snapshot() 524 if regex.search(snapshot.text): 525 self._logger.debug(f"Pattern '{pattern}' found in periodic snapshot") 526 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
528 def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None: 529 """ 530 Wait for a regex pattern to disappear from the terminal output. 531 532 This method efficiently waits for output changes by monitoring the output events 533 from the ht process rather than polling with snapshots. It periodically checks 534 the terminal state to verify the pattern is gone. 535 536 Args: 537 pattern: The regex pattern that should disappear from the terminal output 538 timeout: Maximum time to wait in seconds (default: 5.0) 539 540 Raises: 541 TimeoutError: If the pattern doesn't disappear within the timeout period 542 RuntimeError: If the ht process has exited 543 """ 544 if self._ht_proc.poll() is not None: 545 raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}") 546 547 self._logger.debug(f"Expecting regex pattern to disappear: '{pattern}'") 548 549 # Compile the regex pattern 550 try: 551 regex = re.compile(pattern, re.MULTILINE) 552 except re.error as e: 553 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e 554 555 # Start time for timeout tracking 556 start_time = time.time() 557 558 while True: 559 # Take a snapshot to check current state 560 snapshot = self.snapshot() 561 if not regex.search(snapshot.text): 562 self._logger.debug(f"Pattern '{pattern}' is now absent from terminal output") 563 return 564 565 # Check timeout 566 if time.time() - start_time > timeout: 567 self._logger.debug(f"Pattern '{pattern}' still present in terminal output after {timeout} seconds") 568 raise TimeoutError(f"Pattern '{pattern}' still present after {timeout} seconds") 569 570 # Wait for next event with a short timeout 571 try: 572 event = self._event_queue.get(block=True, timeout=0.1) 573 except queue.Empty: 574 continue 575 576 # Process the event 577 if event["type"] == "output": 578 self._output_events.append(event) 579 elif event["type"] == "exitCode": 580 # Put back in queue for reader thread to handle 581 self._event_queue.put(event) 582 # 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.
69class SnapshotResult: 70 """Result of taking a terminal snapshot""" 71 72 def __init__(self, text: str, html: str, raw_seq: str): 73 self.text = text 74 self.html = html 75 self.raw_seq = raw_seq 76 77 def __repr__(self): 78 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
23class StyleMode(StrEnum): 24 """Style mode for terminal output.""" 25 26 PLAIN = "plain" 27 STYLED = "styled"
Style mode for terminal output.