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'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:

  1. run all instruction, 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 of htty
  • 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'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
147
148# [[[cog
149# import os
150# cog.out(f'__version__ = "{os.environ["HTTY_VERSION"]}"')
151# ]]]
152__version__ = "0.2.28"
153# [[[end]]]
154
155__all__ = [
156    "terminal_session",
157    "run",
158    "HtWrapper",
159    "ProcessController",
160    "SnapshotResult",
161    "Press",
162    "keys",
163    "__version__",
164]
@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]:
584@contextmanager
585def terminal_session(
586    command: Command,
587    rows: Rows = None,
588    cols: Cols = None,
589    logger: Logger = None,
590    extra_subscribes: ExtraSubscribes = None,
591) -> Iterator[HtWrapper]:
592    """
593    The terminal_session context manager is a wrapper around `run` which ensures that the underlying process
594    gets cleaned up:
595
596    ```python
597    with terminal_session("some command") as proc:
598        # interact with the running command here
599        assert proc.exit_code is not None
600        s = proc.snapshot()
601
602    # htty terminates your command on context exit
603    assert proc.exit_code is None
604    assert "hello world" in proc.snapshot()
605    ```
606
607    Its usage is otherwise the same as `run`.
608    Like `run` it returns a `HtWrapper`.
609
610    """
611
612    proc = run(
613        command,
614        rows=rows,
615        cols=cols,
616        no_exit=True,
617        logger=logger,
618        extra_subscribes=extra_subscribes,
619    )
620    try:
621        yield proc
622    finally:
623        try:
624            if proc.cmd.pid:
625                proc.cmd.terminate()
626                proc.cmd.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT)
627        except Exception:
628            try:
629                if proc.cmd.pid:
630                    proc.cmd.kill()
631            except Exception:
632                pass
633
634        try:
635            proc.ht.terminate()
636            proc.ht.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT)
637        except Exception:
638            with suppress(Exception):
639                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.

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:
642def run(
643    command: Command,
644    rows: Rows = None,
645    cols: Cols = None,
646    no_exit: NoExit = True,
647    logger: Logger = None,
648    extra_subscribes: ExtraSubscribes = None,
649) -> HtWrapper:
650    """
651    As a user of the htty python library, your code will run in the python process at the root of this
652    process tree:
653
654        python '{/path/to/your/code.py}'
655        └── ht
656            └── sh -c '{modified command}'
657
658    So if you're using htty to wrap vim, the process tree is:
659
660    ```
661    python
662    └── ht
663        └── sh
664            └── vim
665    ```
666
667    This function invokes `ht` as a subprocess such that you end up with a process tree like the one shown
668    above. It returns an `HtWrapper` object which can be used to interact with ht and its child process.
669
670    It's up to you to clean up this process when you're done:
671
672    ```python
673    proc = run("some command")
674    # do stuff
675    proc.exit()
676    ```
677
678    If you'd rather not risk having a bunch of `ht` processes lying around and wasting CPU cycles,
679    consider using the `terminal_session` instead.
680
681    For reasons that are documented in
682    [htty-core](https://matrixmanatyrservice.github.io/htty/htty-core/htty_core.html#HtEvent.COMMAND_COMPLETED), the
683    command that ht runs is not:
684
685        sh -c '{command}'
686
687    Instead it's something like this:
688
689        sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/tmp/ht_fifo_5432 ; exit $exit_code'
690
691    Because of this, it's possible to come up with command strings that cause sh to behave in problematic ways (for
692    example: `'`). For now the mitigation for this is: "don't do that." (If you'd like me to prioritize changing this
693    please leave a comment in https://github.com/MatrixManAtYrService/htty/issues/2)
694    """
695    # Use provided logger or fall back to default
696    process_logger = logger or default_logger
697
698    # Create a queue for events
699    event_queue: queue.Queue[dict[str, Any]] = queue.Queue()
700
701    # Build the ht subscription list
702    base_subscribes = [
703        HtEvent.INIT,
704        HtEvent.SNAPSHOT,
705        HtEvent.OUTPUT,
706        HtEvent.RESIZE,
707        HtEvent.PID,
708        HtEvent.EXIT_CODE,
709        HtEvent.COMMAND_COMPLETED,
710    ]
711    if extra_subscribes:
712        # Convert string subscribes to HtEvent enum values
713        for sub in extra_subscribes:
714            try:
715                base_subscribes.append(HtEvent(sub))
716            except ValueError:
717                process_logger.warning(f"Unknown subscription event: {sub}")
718
719    # Convert command to string if it's a list, properly escaping shell arguments
720    command_str = command if isinstance(command, str) else " ".join(shlex.quote(arg) for arg in command)
721
722    # Create HtArgs and use htty_core.run()
723    ht_args = HtArgs(
724        command=command_str,  # Use the already-formatted command string
725        subscribes=base_subscribes,
726        rows=rows,
727        cols=cols,
728    )
729
730    # Log the exact command that would be run
731    cmd_args = ht_args.get_command()
732    process_logger.debug(f"Launching command: {' '.join(cmd_args)}")
733
734    ht_proc = htty_core_run(ht_args)
735
736    process_logger.debug(f"ht started: PID {ht_proc.pid}")
737
738    # Create a reader thread to capture ht output
739    def reader_thread(
740        ht_proc: subprocess.Popen[str],
741        queue_obj: queue.Queue[dict[str, Any]],
742        ht_process: HtWrapper,
743        thread_logger: logging.Logger,
744    ) -> None:
745        thread_logger.debug(f"Reader thread started for ht process {ht_proc.pid}")
746
747        while True:
748            if ht_proc.stdout is None:
749                thread_logger.warning(f"ht process {ht_proc.pid} stdout is None, exiting reader thread")
750                break
751
752            line = ht_proc.stdout.readline()
753            if not line:
754                thread_logger.debug(f"ht process {ht_proc.pid} stdout closed, exiting reader thread")
755                break
756
757            line = line.strip()
758            if not line:
759                continue
760
761            try:
762                event = json.loads(line)
763                thread_logger.debug(f"ht event: {event}")
764                queue_obj.put(event)
765
766                if event["type"] == "output":
767                    ht_process.add_output_event(event)
768                elif event["type"] == "exitCode":
769                    thread_logger.debug(
770                        f"ht process {ht_proc.pid} subprocess exited with code: {event.get('data', {}).get('exitCode')}"
771                    )
772                    ht_process.set_subprocess_exited(True)
773                    exit_code = event.get("data", {}).get("exitCode")
774                    if exit_code is not None:
775                        ht_process.cmd.exit_code = exit_code
776                elif event["type"] == "pid":
777                    thread_logger.debug(f"ht process {ht_proc.pid} subprocess PID: {event.get('data', {}).get('pid')}")
778                    pid = event.get("data", {}).get("pid")
779                    if pid is not None:
780                        ht_process.cmd.pid = pid
781                elif event["type"] == "commandCompleted":
782                    # Command has completed - this is the reliable signal that subprocess finished
783                    ht_process.set_subprocess_completed(True)
784                elif event["type"] == "debug":
785                    thread_logger.debug(f"ht process {ht_proc.pid} debug: {event.get('data', {})}")
786                    # Note: We no longer rely on debug events for subprocess_completed
787                    # The commandCompleted event (above) is the reliable source
788            except json.JSONDecodeError as e:
789                # Only log raw stdout when we can't parse it as JSON - this indicates an unexpected message
790                thread_logger.warning(f"ht process {ht_proc.pid} non-JSON stdout: {line} (error: {e})")
791                pass
792
793        thread_logger.debug(f"Reader thread exiting for ht process {ht_proc.pid}")
794
795    # Create an HtWrapper instance
796    process = HtWrapper(
797        ht_proc,
798        event_queue,
799        command=command_str,
800        rows=rows,
801        cols=cols,
802        no_exit=no_exit,
803        logger=process_logger,
804    )
805
806    # Start the reader thread for stdout
807    stdout_thread = threading.Thread(
808        target=reader_thread,
809        args=(ht_proc, event_queue, process, process_logger),
810        daemon=True,
811    )
812    stdout_thread.start()
813
814    # Start a stderr reader thread
815    def stderr_reader_thread(ht_proc: subprocess.Popen[str], thread_logger: logging.Logger) -> None:
816        thread_logger.debug(f"Stderr reader thread started for ht process {ht_proc.pid}")
817
818        while True:
819            if ht_proc.stderr is None:
820                thread_logger.warning(f"ht process {ht_proc.pid} stderr is None, exiting stderr reader thread")
821                break
822
823            line = ht_proc.stderr.readline()
824            if not line:
825                thread_logger.debug(f"ht process {ht_proc.pid} stderr closed, exiting stderr reader thread")
826                break
827
828            line = line.strip()
829            if line:
830                thread_logger.debug(f"ht stderr: {line}")
831
832        thread_logger.debug(f"Stderr reader thread exiting for ht process {ht_proc.pid}")
833
834    stderr_thread = threading.Thread(target=stderr_reader_thread, args=(ht_proc, process_logger), daemon=True)
835    stderr_thread.start()
836
837    # Wait briefly for the process to initialize and get PID
838    start_time = time.time()
839    while time.time() - start_time < 2:
840        try:
841            event = event_queue.get(block=True, timeout=0.5)
842            if event["type"] == "pid":
843                pid = event["data"]["pid"]
844                process.cmd.pid = pid
845                break
846        except queue.Empty:
847            continue
848
849    time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
850    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)

class HtWrapper:
 80class HtWrapper:
 81    """
 82    A wrapper around a process started with the 'ht' tool that provides
 83    methods for interacting with the process and capturing its output.
 84    """
 85
 86    ht: ProcessController
 87    """
 88    Helpers for interacting with the `ht` process (a child process of the python
 89    that called `htty.run` or `htty.terminal_session`)
 90    """
 91
 92    cmd: ProcessController
 93    """
 94    Helpers for interacting with the shell that wraps the caller's command (`ht`'s child process)
 95    """
 96
 97    def __init__(
 98        self,
 99        ht_proc: "subprocess.Popen[str]",
100        event_queue: queue.Queue[dict[str, Any]],
101        command: Optional[str] = None,
102        pid: Optional[int] = None,
103        rows: Optional[int] = None,
104        cols: Optional[int] = None,
105        no_exit: bool = False,
106        logger: Optional[logging.Logger] = None,
107    ) -> None:
108        """
109        @private
110        Users are not expect to create these directly.
111        They should use `with terminal_session(...)` or `run(...)`
112        """
113        self._ht_proc = ht_proc  # The ht process itself
114        self._cmd_process = CmdProcess(pid)
115        self._event_queue = event_queue
116        self._command = command
117        self._output_events: list[dict[str, Any]] = []
118        self._unknown_events: list[dict[str, Any]] = []
119        self._latest_snapshot: Optional[str] = None
120        self._start_time = time.time()
121        self._exit_code: Optional[int] = None
122        self._rows = rows
123        self._cols = cols
124        self._no_exit = no_exit
125        self._subprocess_exited = False
126        self._subprocess_completed = False  # Set earlier when command completion is detected
127
128        # Use provided logger or fall back to default
129        self._logger = logger or default_logger
130        self._logger.debug(f"HTProcess created: ht_proc.pid={ht_proc.pid}, command={command}")
131
132        # Create the public interface objects
133        self.ht: ProcessController = HtProcess(ht_proc, self)
134        self.cmd: ProcessController = self._cmd_process
135
136    def __del__(self):
137        """Destructor to warn about uncleaned processes."""
138        if hasattr(self, "_ht_proc") and self._ht_proc and self._ht_proc.poll() is None:
139            self._logger.warning(
140                f"HTProcess being garbage collected with running ht process (PID: {self._ht_proc.pid}). "
141                f"This may cause resource leaks!"
142            )
143            # Try emergency cleanup
144            with suppress(Exception):
145                self._ht_proc.terminate()
146
147    def get_output(self) -> list[dict[str, Any]]:
148        """
149        Return list of [output](./htty-core/htty_core.html#HtEvent.OUTPUT) events."""
150        return [event for event in self._output_events if event.get("type") == "output"]
151
152    def add_output_event(self, event: dict[str, Any]) -> None:
153        """
154        @private
155        Add an output event (for internal use by reader thread).
156        """
157        self._output_events.append(event)
158
159    def set_subprocess_exited(self, exited: bool) -> None:
160        """
161        @private
162        Set subprocess exited flag (for internal use by reader thread).
163        """
164        self._subprocess_exited = exited
165
166    def set_subprocess_completed(self, completed: bool) -> None:
167        """
168        @private
169        Set subprocess completed flag (for internal use by reader thread).
170        """
171        self._subprocess_completed = completed
172        self._cmd_process.set_completed(completed)
173
174    def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None:
175        """
176        Send keys to the terminal.  Accepts strings, `Press` objects, and lists of strings or `Press` objects.
177        For keys that you can `Press`, see
178        [keys.py](https://github.com/MatrixManAtYrService/htty/blob/main/htty/src/htty/keys.py).
179
180        ```python
181        from htty import Press, terminal_session
182
183        with (
184            terminal_session("sh -i", rows=4, cols=40, logger=test_logger) as sh,
185        ):
186            sh.send_keys("echo foo && sleep 999")
187            sh.send_keys(Press.ENTER)
188            sh.expect("^foo")
189            sh.send_keys(Press.CTRL_Z)
190            sh.expect("Stopped")
191            sh.send_keys(["clear", Press.ENTER])
192        ```
193
194        These are sent to `ht` as events that look like this
195
196        ```json
197        {"type": "sendKeys", "keys": ["echo foo && sleep 999", "Enter"]}
198        ```
199
200        Notice that Press.ENTER is still sent as "Enter" under the hood.
201        `ht` checks to see if it corresponds with a known key and sends it letter-at-a-time if not.
202
203        Because of this, you might run into suprises if you want to type individual characters which happen to spell out
204        a known key such as "Enter" or "Backspace".
205
206        Work around this by breaking up the key names like so:
207
208        ```python
209        sh.send_keys(["Ente", "r"])
210        ```
211
212        If this behavior is problematic for you, consider submitting an issue.
213        """
214        key_strings = keys_to_strings(keys)
215        message = json.dumps({"type": "sendKeys", "keys": key_strings})
216
217        self._logger.debug(f"Sending keys: {message}")
218
219        if self._ht_proc.stdin is not None:
220            try:
221                self._ht_proc.stdin.write(message + "\n")
222                self._ht_proc.stdin.flush()
223                self._logger.debug("Keys sent successfully")
224            except (BrokenPipeError, OSError) as e:
225                self._logger.error(f"Failed to send keys: {e}")
226                self._logger.error(f"ht process poll result: {self._ht_proc.poll()}")
227                raise
228        else:
229            self._logger.error("ht process stdin is None")
230
231        time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
232
233    def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult:
234        """
235        Take a snapshot of the terminal output.
236        """
237        if self._ht_proc.poll() is not None:
238            raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}")
239
240        message = json.dumps({"type": "takeSnapshot"})
241        self._logger.debug(f"Taking snapshot: {message}")
242
243        try:
244            if self._ht_proc.stdin is not None:
245                self._ht_proc.stdin.write(message + "\n")
246                self._ht_proc.stdin.flush()
247                self._logger.debug("Snapshot request sent successfully")
248            else:
249                raise RuntimeError("ht process stdin is not available")
250        except BrokenPipeError as e:
251            self._logger.error(f"Failed to send snapshot request: {e}")
252            self._logger.error(f"ht process poll result: {self._ht_proc.poll()}")
253            raise RuntimeError(
254                f"Cannot communicate with ht process (broken pipe). "
255                f"Process may have exited. Poll result: {self._ht_proc.poll()}"
256            ) from e
257
258        time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
259
260        # Use tenacity to retry getting the snapshot
261        return self._wait_for_snapshot(timeout)
262
263    def _wait_for_snapshot(self, timeout: float) -> SnapshotResult:
264        """
265        Wait for snapshot response from the event queue with timeout and retries.
266        """
267
268        @retry(
269            stop=stop_after_delay(timeout),
270            wait=wait_fixed(SNAPSHOT_RETRY_TIMEOUT),
271            retry=retry_if_exception_type(SnapshotNotReady),
272        )
273        def _get_snapshot() -> SnapshotResult:
274            try:
275                event = self._event_queue.get(block=True, timeout=SNAPSHOT_RETRY_TIMEOUT)
276            except queue.Empty as e:
277                raise SnapshotNotReady("No events available in queue") from e
278
279            if event["type"] == "snapshot":
280                data = event["data"]
281                snapshot_text = data["text"]
282                raw_seq = data["seq"]
283
284                # Convert to HTML with ANSI color support
285                html = simple_ansi_to_html(raw_seq)
286
287                return SnapshotResult(
288                    text=snapshot_text,
289                    html=html,
290                    raw_seq=raw_seq,
291                )
292            elif event["type"] == "output":
293                self._output_events.append(event)
294            elif event["type"] == "resize":
295                data = event.get("data", {})
296                if "rows" in data:
297                    self._rows = data["rows"]
298                if "cols" in data:
299                    self._cols = data["cols"]
300            elif event["type"] == "init":
301                pass
302            else:
303                # Put non-snapshot events back in queue for reader thread to handle
304                self._event_queue.put(event)
305
306            # If we get here, we didn't find a snapshot, so retry
307            raise SnapshotNotReady(f"Received {event['type']} event, waiting for snapshot")
308
309        try:
310            return _get_snapshot()
311        except Exception as e:
312            # Handle both direct SnapshotNotReady and tenacity RetryError
313            from tenacity import RetryError
314
315            if isinstance(e, (SnapshotNotReady, RetryError)):
316                raise RuntimeError(
317                    f"Failed to receive snapshot event within {timeout} seconds. "
318                    f"ht process may have exited or stopped responding."
319                ) from e
320            raise
321
322    def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int:
323        """
324        Exit the ht process, ensuring clean shutdown.
325
326        Uses different strategies based on subprocess state:
327        - If subprocess already exited (exitCode event received): graceful shutdown via exit command
328        - If subprocess still running: forced termination with SIGTERM then SIGKILL
329        """
330        self._logger.debug(f"Exiting HTProcess: ht_proc.pid={self._ht_proc.pid}")
331
332        # Check if we've already received the exitCode event
333        if self._subprocess_exited:
334            self._logger.debug("Subprocess already exited (exitCode event received), attempting graceful shutdown")
335            return self._graceful_exit(timeout)
336        else:
337            self._logger.debug("Subprocess has not exited yet, checking current state")
338
339            # Give a brief moment for any pending exitCode event to arrive
340            brief_wait_start = time.time()
341            while time.time() - brief_wait_start < 0.5:  # Wait up to 500ms
342                if self._subprocess_exited:
343                    self._logger.debug("Subprocess exited during brief wait, attempting graceful shutdown")
344                    return self._graceful_exit(timeout)
345                time.sleep(0.01)
346
347            self._logger.debug("Subprocess still running after brief wait, using forced termination")
348            return self._forced_exit(timeout)
349
350    def _graceful_exit(self, timeout: float) -> int:
351        """
352        Graceful exit: subprocess has completed, so we can send exit command to ht process.
353        """
354        # Send exit command to ht process
355        message = json.dumps({"type": "exit"})
356        self._logger.debug(f"Sending exit command to ht process {self._ht_proc.pid}: {message}")
357
358        try:
359            if self._ht_proc.stdin is not None:
360                self._ht_proc.stdin.write(message + "\n")
361                self._ht_proc.stdin.flush()
362                self._logger.debug(f"Exit command sent successfully to ht process {self._ht_proc.pid}")
363                self._ht_proc.stdin.close()  # Close stdin after sending exit command
364                self._logger.debug(f"Closed stdin for ht process {self._ht_proc.pid}")
365            else:
366                self._logger.debug(f"ht process {self._ht_proc.pid} stdin is None, cannot send exit command")
367        except (BrokenPipeError, OSError) as e:
368            self._logger.debug(
369                f"Failed to send exit command to ht process {self._ht_proc.pid}: {e} (process may have already exited)"
370            )
371            pass
372
373        # Wait for the ht process to finish gracefully
374        start_time = time.time()
375        while self._ht_proc.poll() is None:
376            if time.time() - start_time > timeout:
377                # Graceful exit timed out, fall back to forced termination
378                self._logger.warning(
379                    f"ht process {self._ht_proc.pid} did not exit gracefully within timeout, "
380                    f"falling back to forced termination"
381                )
382                return self._forced_exit(timeout)
383            time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
384
385        self._exit_code = self._ht_proc.returncode
386        if self._exit_code is None:
387            raise RuntimeError("Failed to determine ht process exit code")
388
389        self._logger.debug(f"HTProcess exited gracefully: exit_code={self._exit_code}")
390        return self._exit_code
391
392    def _forced_exit(self, timeout: float) -> int:
393        """
394        Forced exit: subprocess may still be running, so we need to terminate everything forcefully.
395        """
396        # Step 1: Ensure subprocess is terminated first if needed
397        if self._cmd_process.pid and not self._subprocess_exited:
398            self._logger.debug(f"Terminating subprocess: pid={self._cmd_process.pid}")
399            try:
400                os.kill(self._cmd_process.pid, 0)
401                self._cmd_process.terminate()
402                try:
403                    self._cmd_process.wait(timeout=DEFAULT_SUBPROCESS_WAIT_TIMEOUT)
404                    self._logger.debug(f"Subprocess {self._cmd_process.pid} terminated successfully")
405                except Exception:
406                    self._logger.warning(f"Subprocess {self._cmd_process.pid} did not terminate gracefully, killing")
407                    with suppress(Exception):
408                        self._cmd_process.kill()
409            except OSError:
410                self._logger.debug(f"Subprocess {self._cmd_process.pid} already exited")
411                pass  # Process already exited
412
413        # Step 2: Force terminate the ht process with SIGTERM, then SIGKILL if needed
414        self._logger.debug(f"Force terminating ht process {self._ht_proc.pid}")
415
416        # Try SIGTERM first
417        try:
418            self._ht_proc.terminate()
419            self._logger.debug(f"Sent SIGTERM to ht process {self._ht_proc.pid}")
420        except Exception as e:
421            self._logger.debug(f"Failed to send SIGTERM to ht process {self._ht_proc.pid}: {e}")
422
423        # Wait for termination
424        start_time = time.time()
425        while self._ht_proc.poll() is None:
426            if time.time() - start_time > timeout:
427                # SIGTERM timeout, try SIGKILL
428                self._logger.warning(
429                    f"ht process {self._ht_proc.pid} did not terminate with SIGTERM within timeout, sending SIGKILL"
430                )
431                try:
432                    self._ht_proc.kill()
433                    self._logger.debug(f"Sent SIGKILL to ht process {self._ht_proc.pid}")
434                except Exception as e:
435                    self._logger.debug(f"Failed to send SIGKILL to ht process {self._ht_proc.pid}: {e}")
436
437                # Wait for SIGKILL to take effect
438                kill_start_time = time.time()
439                while self._ht_proc.poll() is None:
440                    if time.time() - kill_start_time > timeout:
441                        self._logger.error(f"ht process {self._ht_proc.pid} did not respond to SIGKILL within timeout")
442                        break
443                    time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
444                break
445            time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
446
447        self._exit_code = self._ht_proc.returncode
448        if self._exit_code is None:
449            raise RuntimeError("Failed to determine ht process exit code")
450
451        self._logger.debug(f"HTProcess exited via forced termination: exit_code={self._exit_code}")
452        return self._exit_code
453
454    def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None:
455        """
456        Wait for a regex pattern to appear in the terminal output.
457
458        This method efficiently waits for output by monitoring the output events from
459        the ht process rather than polling with snapshots. It checks both the current
460        terminal state (via snapshot) and any new output that arrives.
461
462        Args:
463            pattern: The regex pattern to look for in the terminal output
464            timeout: Maximum time to wait in seconds (default: 5.0)
465
466        Raises:
467            TimeoutError: If the pattern doesn't appear 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: '{pattern}'")
474
475        # Compile the regex pattern
476        try:
477            regex = re.compile(pattern, re.MULTILINE)
478        except re.error as e:
479            raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
480
481        # First check current terminal state
482        snapshot = self.snapshot()
483        if regex.search(snapshot.text):
484            self._logger.debug(f"Pattern '{pattern}' found immediately in current terminal state")
485            return
486
487        # Start time for timeout tracking
488        start_time = time.time()
489
490        # Process events until we find the pattern or timeout
491        while True:
492            # Check timeout
493            if time.time() - start_time > timeout:
494                self._logger.debug(f"Pattern '{pattern}' not found in terminal output after {timeout} seconds")
495                raise TimeoutError(f"Pattern '{pattern}' not found within {timeout} seconds")
496
497            try:
498                # Wait for next event with a short timeout to allow checking the overall timeout
499                event = self._event_queue.get(block=True, timeout=0.1)
500            except queue.Empty:
501                continue
502
503            # Process the event
504            if event["type"] == "output":
505                self._output_events.append(event)
506                # Check if pattern appears in this output
507                if "data" in event and "seq" in event["data"] and regex.search(event["data"]["seq"]):
508                    self._logger.debug(f"Pattern '{pattern}' found in output event")
509                    return
510            elif event["type"] == "exitCode":
511                # Put back in queue for reader thread to handle
512                self._event_queue.put(event)
513                # Don't raise here - the process might have exited after outputting what we want
514            elif event["type"] == "snapshot":
515                # If we get a snapshot event, check its content
516                if "data" in event and "text" in event["data"] and regex.search(event["data"]["text"]):
517                    self._logger.debug(f"Pattern '{pattern}' found in snapshot event")
518                    return
519
520            # Take a new snapshot periodically to catch any missed output
521            if len(self._output_events) % 10 == 0:  # Every 10 events
522                snapshot = self.snapshot()
523                if regex.search(snapshot.text):
524                    self._logger.debug(f"Pattern '{pattern}' found in periodic snapshot")
525                    return
526
527    def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None:
528        """
529        Wait for a regex pattern to disappear from the terminal output.
530
531        This method efficiently waits for output changes by monitoring the output events
532        from the ht process rather than polling with snapshots. It periodically checks
533        the terminal state to verify the pattern is gone.
534
535        Args:
536            pattern: The regex pattern that should disappear from the terminal output
537            timeout: Maximum time to wait in seconds (default: 5.0)
538
539        Raises:
540            TimeoutError: If the pattern doesn't disappear within the timeout period
541            RuntimeError: If the ht process has exited
542        """
543        if self._ht_proc.poll() is not None:
544            raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}")
545
546        self._logger.debug(f"Expecting regex pattern to disappear: '{pattern}'")
547
548        # Compile the regex pattern
549        try:
550            regex = re.compile(pattern, re.MULTILINE)
551        except re.error as e:
552            raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
553
554        # Start time for timeout tracking
555        start_time = time.time()
556
557        while True:
558            # Take a snapshot to check current state
559            snapshot = self.snapshot()
560            if not regex.search(snapshot.text):
561                self._logger.debug(f"Pattern '{pattern}' is now absent from terminal output")
562                return
563
564            # Check timeout
565            if time.time() - start_time > timeout:
566                self._logger.debug(f"Pattern '{pattern}' still present in terminal output after {timeout} seconds")
567                raise TimeoutError(f"Pattern '{pattern}' still present after {timeout} seconds")
568
569            # Wait for next event with a short timeout
570            try:
571                event = self._event_queue.get(block=True, timeout=0.1)
572            except queue.Empty:
573                continue
574
575            # Process the event
576            if event["type"] == "output":
577                self._output_events.append(event)
578            elif event["type"] == "exitCode":
579                # Put back in queue for reader thread to handle
580                self._event_queue.put(event)
581                # 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)

def get_output(self) -> list[dict[str, typing.Any]]:
147    def get_output(self) -> list[dict[str, Any]]:
148        """
149        Return list of [output](./htty-core/htty_core.html#HtEvent.OUTPUT) events."""
150        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:
174    def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None:
175        """
176        Send keys to the terminal.  Accepts strings, `Press` objects, and lists of strings or `Press` objects.
177        For keys that you can `Press`, see
178        [keys.py](https://github.com/MatrixManAtYrService/htty/blob/main/htty/src/htty/keys.py).
179
180        ```python
181        from htty import Press, terminal_session
182
183        with (
184            terminal_session("sh -i", rows=4, cols=40, logger=test_logger) as sh,
185        ):
186            sh.send_keys("echo foo && sleep 999")
187            sh.send_keys(Press.ENTER)
188            sh.expect("^foo")
189            sh.send_keys(Press.CTRL_Z)
190            sh.expect("Stopped")
191            sh.send_keys(["clear", Press.ENTER])
192        ```
193
194        These are sent to `ht` as events that look like this
195
196        ```json
197        {"type": "sendKeys", "keys": ["echo foo && sleep 999", "Enter"]}
198        ```
199
200        Notice that Press.ENTER is still sent as "Enter" under the hood.
201        `ht` checks to see if it corresponds with a known key and sends it letter-at-a-time if not.
202
203        Because of this, you might run into suprises if you want to type individual characters which happen to spell out
204        a known key such as "Enter" or "Backspace".
205
206        Work around this by breaking up the key names like so:
207
208        ```python
209        sh.send_keys(["Ente", "r"])
210        ```
211
212        If this behavior is problematic for you, consider submitting an issue.
213        """
214        key_strings = keys_to_strings(keys)
215        message = json.dumps({"type": "sendKeys", "keys": key_strings})
216
217        self._logger.debug(f"Sending keys: {message}")
218
219        if self._ht_proc.stdin is not None:
220            try:
221                self._ht_proc.stdin.write(message + "\n")
222                self._ht_proc.stdin.flush()
223                self._logger.debug("Keys sent successfully")
224            except (BrokenPipeError, OSError) as e:
225                self._logger.error(f"Failed to send keys: {e}")
226                self._logger.error(f"ht process poll result: {self._ht_proc.poll()}")
227                raise
228        else:
229            self._logger.error("ht process stdin is None")
230
231        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.

def snapshot(self, timeout: float = 5.0) -> SnapshotResult:
233    def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult:
234        """
235        Take a snapshot of the terminal output.
236        """
237        if self._ht_proc.poll() is not None:
238            raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}")
239
240        message = json.dumps({"type": "takeSnapshot"})
241        self._logger.debug(f"Taking snapshot: {message}")
242
243        try:
244            if self._ht_proc.stdin is not None:
245                self._ht_proc.stdin.write(message + "\n")
246                self._ht_proc.stdin.flush()
247                self._logger.debug("Snapshot request sent successfully")
248            else:
249                raise RuntimeError("ht process stdin is not available")
250        except BrokenPipeError as e:
251            self._logger.error(f"Failed to send snapshot request: {e}")
252            self._logger.error(f"ht process poll result: {self._ht_proc.poll()}")
253            raise RuntimeError(
254                f"Cannot communicate with ht process (broken pipe). "
255                f"Process may have exited. Poll result: {self._ht_proc.poll()}"
256            ) from e
257
258        time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
259
260        # Use tenacity to retry getting the snapshot
261        return self._wait_for_snapshot(timeout)

Take a snapshot of the terminal output.

def exit(self, timeout: float = 5.0) -> int:
322    def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int:
323        """
324        Exit the ht process, ensuring clean shutdown.
325
326        Uses different strategies based on subprocess state:
327        - If subprocess already exited (exitCode event received): graceful shutdown via exit command
328        - If subprocess still running: forced termination with SIGTERM then SIGKILL
329        """
330        self._logger.debug(f"Exiting HTProcess: ht_proc.pid={self._ht_proc.pid}")
331
332        # Check if we've already received the exitCode event
333        if self._subprocess_exited:
334            self._logger.debug("Subprocess already exited (exitCode event received), attempting graceful shutdown")
335            return self._graceful_exit(timeout)
336        else:
337            self._logger.debug("Subprocess has not exited yet, checking current state")
338
339            # Give a brief moment for any pending exitCode event to arrive
340            brief_wait_start = time.time()
341            while time.time() - brief_wait_start < 0.5:  # Wait up to 500ms
342                if self._subprocess_exited:
343                    self._logger.debug("Subprocess exited during brief wait, attempting graceful shutdown")
344                    return self._graceful_exit(timeout)
345                time.sleep(0.01)
346
347            self._logger.debug("Subprocess still running after brief wait, using forced termination")
348            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:
454    def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None:
455        """
456        Wait for a regex pattern to appear in the terminal output.
457
458        This method efficiently waits for output by monitoring the output events from
459        the ht process rather than polling with snapshots. It checks both the current
460        terminal state (via snapshot) and any new output that arrives.
461
462        Args:
463            pattern: The regex pattern to look for in the terminal output
464            timeout: Maximum time to wait in seconds (default: 5.0)
465
466        Raises:
467            TimeoutError: If the pattern doesn't appear 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: '{pattern}'")
474
475        # Compile the regex pattern
476        try:
477            regex = re.compile(pattern, re.MULTILINE)
478        except re.error as e:
479            raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
480
481        # First check current terminal state
482        snapshot = self.snapshot()
483        if regex.search(snapshot.text):
484            self._logger.debug(f"Pattern '{pattern}' found immediately in current terminal state")
485            return
486
487        # Start time for timeout tracking
488        start_time = time.time()
489
490        # Process events until we find the pattern or timeout
491        while True:
492            # Check timeout
493            if time.time() - start_time > timeout:
494                self._logger.debug(f"Pattern '{pattern}' not found in terminal output after {timeout} seconds")
495                raise TimeoutError(f"Pattern '{pattern}' not found within {timeout} seconds")
496
497            try:
498                # Wait for next event with a short timeout to allow checking the overall timeout
499                event = self._event_queue.get(block=True, timeout=0.1)
500            except queue.Empty:
501                continue
502
503            # Process the event
504            if event["type"] == "output":
505                self._output_events.append(event)
506                # Check if pattern appears in this output
507                if "data" in event and "seq" in event["data"] and regex.search(event["data"]["seq"]):
508                    self._logger.debug(f"Pattern '{pattern}' found in output event")
509                    return
510            elif event["type"] == "exitCode":
511                # Put back in queue for reader thread to handle
512                self._event_queue.put(event)
513                # Don't raise here - the process might have exited after outputting what we want
514            elif event["type"] == "snapshot":
515                # If we get a snapshot event, check its content
516                if "data" in event and "text" in event["data"] and regex.search(event["data"]["text"]):
517                    self._logger.debug(f"Pattern '{pattern}' found in snapshot event")
518                    return
519
520            # Take a new snapshot periodically to catch any missed output
521            if len(self._output_events) % 10 == 0:  # Every 10 events
522                snapshot = self.snapshot()
523                if regex.search(snapshot.text):
524                    self._logger.debug(f"Pattern '{pattern}' found in periodic snapshot")
525                    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:
527    def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None:
528        """
529        Wait for a regex pattern to disappear from the terminal output.
530
531        This method efficiently waits for output changes by monitoring the output events
532        from the ht process rather than polling with snapshots. It periodically checks
533        the terminal state to verify the pattern is gone.
534
535        Args:
536            pattern: The regex pattern that should disappear from the terminal output
537            timeout: Maximum time to wait in seconds (default: 5.0)
538
539        Raises:
540            TimeoutError: If the pattern doesn't disappear within the timeout period
541            RuntimeError: If the ht process has exited
542        """
543        if self._ht_proc.poll() is not None:
544            raise RuntimeError(f"ht process has exited with code {self._ht_proc.returncode}")
545
546        self._logger.debug(f"Expecting regex pattern to disappear: '{pattern}'")
547
548        # Compile the regex pattern
549        try:
550            regex = re.compile(pattern, re.MULTILINE)
551        except re.error as e:
552            raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
553
554        # Start time for timeout tracking
555        start_time = time.time()
556
557        while True:
558            # Take a snapshot to check current state
559            snapshot = self.snapshot()
560            if not regex.search(snapshot.text):
561                self._logger.debug(f"Pattern '{pattern}' is now absent from terminal output")
562                return
563
564            # Check timeout
565            if time.time() - start_time > timeout:
566                self._logger.debug(f"Pattern '{pattern}' still present in terminal output after {timeout} seconds")
567                raise TimeoutError(f"Pattern '{pattern}' still present after {timeout} seconds")
568
569            # Wait for next event with a short timeout
570            try:
571                event = self._event_queue.get(block=True, timeout=0.1)
572            except queue.Empty:
573                continue
574
575            # Process the event
576            if event["type"] == "output":
577                self._output_events.append(event)
578            elif event["type"] == "exitCode":
579                # Put back in queue for reader thread to handle
580                self._event_queue.put(event)
581                # 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:
68class SnapshotResult:
69    """Result of taking a terminal snapshot"""
70
71    def __init__(self, text: str, html: str, raw_seq: str):
72        self.text = text
73        self.html = html
74        self.raw_seq = raw_seq
75
76    def __repr__(self):
77        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)
71    def __init__(self, text: str, html: str, raw_seq: str):
72        self.text = text
73        self.html = html
74        self.raw_seq = raw_seq
text
html
raw_seq
__version__ = '0.2.28'