htty_core

htty-core: Core headless terminal functionality with bundled ht binary.

This package provides the minimal interface for running ht processes.

 1"""
 2htty-core: Core headless terminal functionality with bundled ht binary.
 3
 4This package provides the minimal interface for running ht processes.
 5"""
 6
 7from .core import Cols, Command, HtArgs, HtEvent, Rows, find_ht_binary, run
 8
 9__all__ = ["HtArgs", "HtEvent", "find_ht_binary", "run", "Command", "Rows", "Cols", "__version__"]
10# [[[cog
11# import os
12# cog.out(f'__version__ = "{os.environ["HTTY_VERSION"]}"')
13# ]]]
14__version__ = "0.2.25"
15# [[[end]]]
class HtArgs:
139class HtArgs:
140    """
141    The caller provides one of these when they want an `ht` process.
142    """
143
144    def __init__(
145        self,
146        command: Command,
147        subscribes: Optional[list[HtEvent]] = None,
148        rows: Rows = None,
149        cols: Cols = None,
150    ) -> None:
151        self.command = command
152        self.subscribes = subscribes or []
153        self.rows = rows
154        self.cols = cols
155
156    def get_command(self, ht_binary: Optional[str] = None) -> list[str]:
157        """Build the command line arguments for running ht.
158
159        Args:
160            ht_binary: Optional path to ht binary. If not provided, find_ht_binary() will be called.
161
162        Returns:
163            List of command arguments that would be passed to subprocess.Popen
164        """
165        if ht_binary is None:
166            ht_binary = find_ht_binary()
167
168        cmd_args = [ht_binary]
169
170        # Add subscription arguments
171        if self.subscribes:
172            subscribe_strings = [event.value for event in self.subscribes]
173            cmd_args.extend(["--subscribe", ",".join(subscribe_strings)])
174
175        # Add size arguments if specified
176        if self.rows is not None and self.cols is not None:
177            cmd_args.extend(["--size", f"{self.cols}x{self.rows}"])
178
179        # Add separator and the command to run
180        cmd_args.append("--")
181        if isinstance(self.command, str):
182            cmd_args.extend(self.command.split())
183        else:
184            cmd_args.extend(self.command)
185
186        return cmd_args

The caller provides one of these when they want an ht process.

HtArgs( command: Annotated[Union[str, list[str]], 'run this command (as a subprocess of ht)'], subscribes: Optional[list[HtEvent]] = None, 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)
144    def __init__(
145        self,
146        command: Command,
147        subscribes: Optional[list[HtEvent]] = None,
148        rows: Rows = None,
149        cols: Cols = None,
150    ) -> None:
151        self.command = command
152        self.subscribes = subscribes or []
153        self.rows = rows
154        self.cols = cols
command
subscribes
rows
cols
def get_command(self, ht_binary: Optional[str] = None) -> list[str]:
156    def get_command(self, ht_binary: Optional[str] = None) -> list[str]:
157        """Build the command line arguments for running ht.
158
159        Args:
160            ht_binary: Optional path to ht binary. If not provided, find_ht_binary() will be called.
161
162        Returns:
163            List of command arguments that would be passed to subprocess.Popen
164        """
165        if ht_binary is None:
166            ht_binary = find_ht_binary()
167
168        cmd_args = [ht_binary]
169
170        # Add subscription arguments
171        if self.subscribes:
172            subscribe_strings = [event.value for event in self.subscribes]
173            cmd_args.extend(["--subscribe", ",".join(subscribe_strings)])
174
175        # Add size arguments if specified
176        if self.rows is not None and self.cols is not None:
177            cmd_args.extend(["--size", f"{self.cols}x{self.rows}"])
178
179        # Add separator and the command to run
180        cmd_args.append("--")
181        if isinstance(self.command, str):
182            cmd_args.extend(self.command.split())
183        else:
184            cmd_args.extend(self.command)
185
186        return cmd_args

Build the command line arguments for running ht.

Args: ht_binary: Optional path to ht binary. If not provided, find_ht_binary() will be called.

Returns: List of command arguments that would be passed to subprocess.Popen

class HtEvent(enum.StrEnum):
 45class HtEvent(StrEnum):
 46    """
 47    Event types that can be subscribed to from the ht process.
 48
 49    The original set of events is documented [in the ht repo](https://github.com/andyk/ht?tab=readme-ov-file#events).
 50
 51    Events added by `htty`:
 52
 53    - pid
 54    - exitCode
 55    - debug
 56    - completed
 57    """
 58
 59    INIT = "init"
 60    """
 61    Same as snapshot event (see below) but sent only once, as the first event after ht's start (when sent to
 62    STDOUT) and upon establishing of WebSocket connection.
 63    """
 64
 65    SNAPSHOT = "snapshot"
 66    """
 67    Terminal window snapshot. Sent when the terminal snapshot is taken with the takeSnapshot command.
 68
 69    Event data is an object with the following fields:
 70
 71    - cols - current terminal width, number of columns
 72    - rows - current terminal height, number of rows
 73    - text - plain text snapshot as multi-line string, where each line represents a terminal row
 74    - seq - a raw sequence of characters, which when printed to a blank terminal puts it in the same state as
 75      ht's virtual terminal
 76    """
 77
 78    OUTPUT = "output"
 79    """
 80    Terminal output. Sent when an application (e.g. shell) running under ht prints something to the terminal.
 81
 82    Event data is an object with the following fields:
 83
 84    - seq - a raw sequence of characters written to a terminal, potentially including control sequences
 85      (colors, cursor positioning, etc.)
 86    """
 87
 88    RESIZE = "resize"
 89    """
 90    Terminal resize. Send when the terminal is resized with the resize command.
 91
 92    Event data is an object with the following fields:
 93
 94    - cols - current terminal width, number of columns
 95    - rows - current terminal height, number of rows
 96    """
 97
 98    PID = "pid"
 99    """
100    ht runs the indicated command in `sh`.
101    This event provides the pid of that `sh` process
102    """
103
104    EXIT_CODE = "exitCode"
105    """
106    htty modified ht to stay open even after the command has completed.
107    This event indicates the exit code of the underlying command.
108    """
109
110    COMMAND_COMPLETED = "commandCompleted"
111    """
112    htty modified ht to run your command like so:
113
114    Previously, ht did the simple thing and ran your command like this:
115    ```
116    sh -c '{command}''
117    ```
118
119    Sometimes, the PTY would shut down before all output was processed by ht, causing snapshots taken
120    after exit to be incomplete.
121    To fix this htty modified ht to run your like so:
122
123    ```
124    sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/a/temp/fifo ; exit $exit_code'
125    ```
126
127    (The fifo is used to notify `ht wait-exit` that it's safe to exit)
128
129    Following this change, the command might complete at one time, and the exit code would be made available later.
130    This event indicates when the command completed, exitCode appears when the shell exits.
131    """
132
133    DEBUG = "debug"
134    """
135    These events contain messages that might be helpful for debugging `ht`.
136    """

Event types that can be subscribed to from the ht process.

The original set of events is documented in the ht repo.

Events added by htty:

  • pid
  • exitCode
  • debug
  • completed
INIT = <HtEvent.INIT: 'init'>

Same as snapshot event (see below) but sent only once, as the first event after ht's start (when sent to STDOUT) and upon establishing of WebSocket connection.

SNAPSHOT = <HtEvent.SNAPSHOT: 'snapshot'>

Terminal window snapshot. Sent when the terminal snapshot is taken with the takeSnapshot command.

Event data is an object with the following fields:

  • cols - current terminal width, number of columns
  • rows - current terminal height, number of rows
  • text - plain text snapshot as multi-line string, where each line represents a terminal row
  • seq - a raw sequence of characters, which when printed to a blank terminal puts it in the same state as ht's virtual terminal
OUTPUT = <HtEvent.OUTPUT: 'output'>

Terminal output. Sent when an application (e.g. shell) running under ht prints something to the terminal.

Event data is an object with the following fields:

  • seq - a raw sequence of characters written to a terminal, potentially including control sequences (colors, cursor positioning, etc.)
RESIZE = <HtEvent.RESIZE: 'resize'>

Terminal resize. Send when the terminal is resized with the resize command.

Event data is an object with the following fields:

  • cols - current terminal width, number of columns
  • rows - current terminal height, number of rows
PID = <HtEvent.PID: 'pid'>

ht runs the indicated command in sh. This event provides the pid of that sh process

EXIT_CODE = <HtEvent.EXIT_CODE: 'exitCode'>

htty modified ht to stay open even after the command has completed. This event indicates the exit code of the underlying command.

COMMAND_COMPLETED = <HtEvent.COMMAND_COMPLETED: 'commandCompleted'>

htty modified ht to run your command like so:

Previously, ht did the simple thing and ran your command like this:

sh -c '{command}''

Sometimes, the PTY would shut down before all output was processed by ht, causing snapshots taken after exit to be incomplete. To fix this htty modified ht to run your like so:

sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/a/temp/fifo ; exit $exit_code'

(The fifo is used to notify ht wait-exit that it's safe to exit)

Following this change, the command might complete at one time, and the exit code would be made available later. This event indicates when the command completed, exitCode appears when the shell exits.

DEBUG = <HtEvent.DEBUG: 'debug'>

These events contain messages that might be helpful for debugging ht.

def find_ht_binary() -> str:
189def find_ht_binary() -> str:
190    """Find the bundled ht binary."""
191    # Check HTTY_HT_BIN environment variable first
192    env_path = os.environ.get("HTTY_HT_BIN")
193    if env_path and os.path.isfile(env_path):
194        return env_path
195
196    ht_exe = "ht" + (sysconfig.get_config_var("EXE") or "")
197
198    # First, try to find the binary relative to this package installation
199    pkg_file = __file__  # This file: .../site-packages/htty_core/core.py
200    pkg_dir = os.path.dirname(pkg_file)  # .../site-packages/htty_core/
201    site_packages = os.path.dirname(pkg_dir)  # .../site-packages/
202    python_env = os.path.dirname(site_packages)  # .../lib/python3.x/
203    env_root = os.path.dirname(python_env)  # .../lib/
204    actual_env_root = os.path.dirname(env_root)  # The actual environment root
205
206    # Look for binary in the environment's bin directory
207    env_bin_path = os.path.join(actual_env_root, "bin", ht_exe)
208    if os.path.isfile(env_bin_path):
209        return env_bin_path
210
211    # Only look for the bundled binary - no system fallbacks
212    raise FileNotFoundError(
213        f"Bundled ht binary not found at expected location: {env_bin_path}. "
214        f"This indicates a packaging issue with htty-core."
215    )

Find the bundled ht binary.

def run(args: HtArgs) -> pdoc.extract._PdocDefusedPopen[str]:
218def run(args: HtArgs) -> subprocess.Popen[str]:
219    """
220    Given some `HtArgs` object, run its command via ht.
221
222    `ht` connect
223
224
225    Returns a subprocess.Popen object representing the running ht process.
226    The caller is responsible for managing the process lifecycle.
227    """
228    cmd_args = args.get_command()
229
230    # Start the process
231    return subprocess.Popen(
232        cmd_args,
233        stdin=subprocess.PIPE,
234        stdout=subprocess.PIPE,
235        stderr=subprocess.PIPE,
236        text=True,
237        bufsize=1,
238    )

Given some HtArgs object, run its command via ht.

ht connect

Returns a subprocess.Popen object representing the running ht process. The caller is responsible for managing the process lifecycle.

Command = typing.Annotated[typing.Union[str, list[str]], 'run this command (as a subprocess of ht)']
Rows = typing.Annotated[typing.Optional[int], 'number of rows for the headless terminal (default: 30)']
Cols = typing.Annotated[typing.Optional[int], 'number of columns for the headless terminal (default: 60)']
__version__ = '0.2.25'