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 IMprovedversion 9.0.2136by Bram Moolenaar et al.Vim is open source and freely distributableHelp 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 of htty 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 IMprovedversion 9.0.2136by Bram Moolenaar et al.Vim is open source and freely distributableHelp 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]
@contextmanager
def terminal_session( command: Annotated[Union[str, list[str]], 'run this command (as a subprocess of ht)'], rows: Annotated[Optional[int], 'number of rows for the headless terminal (default: 30)'] = None, cols: Annotated[Optional[int], 'number of columns for the headless terminal (default: 60)'] = None, logger: Annotated[Optional[logging.Logger], 'callers can override the default logger with their own'] = None, extra_subscribes: Annotated[Optional[list[htty_core.core.HtEvent]], 'additional event types to subscribe to'] = None) -> Iterator[HtWrapper]:
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

def run( command: Annotated[Union[str, list[str]], 'run this command (as a subprocess of ht)'], rows: Annotated[Optional[int], 'number of rows for the headless terminal (default: 30)'] = None, cols: Annotated[Optional[int], 'number of columns for the headless terminal (default: 60)'] = None, no_exit: Annotated[bool, "whether to keep ht running even after the underlying command exits\nallows the caller to take snapshots even after the command has completed\nrequires the caller to send an explicit 'exit' event to cause ht to exit\n"] = True, logger: Annotated[Optional[logging.Logger], 'callers can override the default logger with their own'] = None, extra_subscribes: Annotated[Optional[list[htty_core.core.HtEvent]], 'additional event types to subscribe to'] = None) -> HtWrapper:
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.

class HtWrapper:
 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.

def get_output(self) -> list[dict[str, typing.Any]]:
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.

def send_keys( self, keys: Union[htty.keys.Press, str, list[Union[htty.keys.Press, str]]]) -> None:
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.

def snapshot(self, timeout: float = 5.0) -> SnapshotResult:
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.

def exit(self, timeout: float = 5.0) -> int:
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
def expect(self, pattern: str, timeout: float = 5.0) -> None:
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

def expect_absent(self, pattern: str, timeout: float = 5.0) -> None:
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

class ProcessController(typing.Protocol):
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.

ProcessController(*args, **kwargs)
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)
def exit(self, timeout: Optional[float] = None) -> int:
23    def exit(self, timeout: Optional[float] = None) -> int:
24        """Exit the process."""
25        ...

Exit the process.

def terminate(self) -> None:
27    def terminate(self) -> None:
28        """Terminate the process."""
29        ...

Terminate the process.

def kill(self) -> None:
31    def kill(self) -> None:
32        """Force kill the process."""
33        ...

Force kill the process.

def wait(self, timeout: Optional[float] = None) -> Optional[int]:
35    def wait(self, timeout: Optional[float] = None) -> Optional[int]:
36        """Wait for the process to finish."""
37        ...

Wait for the process to finish.

def poll(self) -> Optional[int]:
39    def poll(self) -> Optional[int]:
40        """Check if the process is still running."""
41        ...

Check if the process is still running.

pid: Optional[int]
43    @property
44    def pid(self) -> Optional[int]:
45        """Get the process ID."""
46        ...

Get the process ID.

exit_code: Optional[int]
53    @property
54    def exit_code(self) -> Optional[int]:
55        """Get the exit code of the process."""
56        ...

Get the exit code of the process.

completed: bool
63    @property
64    def completed(self) -> bool:
65        """Check if the process has completed."""
66        ...

Check if the process has completed.

class SnapshotResult:
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

SnapshotResult(text: str, html: str, raw_seq: str)
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
text
html
raw_seq
__version__ = '0.2.25'