htty_core

htty-core: A thin wrapper around a forked ht binary for use with htty.

 1"""
 2htty-core: A thin wrapper around a forked [ht](https://github.com/andyk/ht) binary for use with [htty](https://matrixmanatyrservice.github.io/htty/htty.html).
 3"""
 4
 5from .core import Cols, Command, HtArgs, HtEvent, Rows, StyleMode, find_ht_binary, run
 6
 7__all__ = ["HtArgs", "HtEvent", "find_ht_binary", "run", "Command", "Rows", "Cols", "StyleMode", "__version__"]
 8# [[[cog
 9# import os
10# cog.out(f'__version__ = "{os.environ["HTTY_VERSION"]}"')
11# ]]]
12__version__ = "0.2.30"
13# [[[end]]]
class HtArgs:
147class HtArgs:
148    """
149    The caller provides one of these when they want an `ht` process.
150    """
151
152    def __init__(
153        self,
154        command: Command,
155        subscribes: Optional[list[HtEvent]] = None,
156        rows: Rows = None,
157        cols: Cols = None,
158        style_mode: Optional[StyleMode] = None,
159    ) -> None:
160        self.command = command
161        self.subscribes = subscribes or []
162        self.rows = rows
163        self.cols = cols
164        self.style_mode = style_mode
165
166    def get_command(self, ht_binary: Optional[str] = None) -> list[str]:
167        """Build the command line arguments for running ht.
168
169        Args:
170            ht_binary: Optional path to ht binary. If not provided, find_ht_binary() will be called.
171
172        Returns:
173            List of command arguments that would be passed to subprocess.Popen
174        """
175        if ht_binary is None:
176            ht_binary = find_ht_binary()
177
178        cmd_args = [ht_binary]
179
180        # Add subscription arguments
181        if self.subscribes:
182            subscribe_strings = [event.value for event in self.subscribes]
183            cmd_args.extend(["--subscribe", ",".join(subscribe_strings)])
184
185        # Add size arguments if specified
186        if self.rows is not None and self.cols is not None:
187            cmd_args.extend(["--size", f"{self.cols}x{self.rows}"])
188
189        # Add style mode if specified
190        if self.style_mode is not None:
191            cmd_args.extend(["--style-mode", self.style_mode])
192
193        # Add separator and the command to run
194        cmd_args.append("--")
195        if isinstance(self.command, str):
196            cmd_args.extend(self.command.split())
197        else:
198            cmd_args.extend(self.command)
199
200        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, style_mode: Optional[StyleMode] = None)
152    def __init__(
153        self,
154        command: Command,
155        subscribes: Optional[list[HtEvent]] = None,
156        rows: Rows = None,
157        cols: Cols = None,
158        style_mode: Optional[StyleMode] = None,
159    ) -> None:
160        self.command = command
161        self.subscribes = subscribes or []
162        self.rows = rows
163        self.cols = cols
164        self.style_mode = style_mode
command
subscribes
rows
cols
style_mode
def get_command(self, ht_binary: Optional[str] = None) -> list[str]:
166    def get_command(self, ht_binary: Optional[str] = None) -> list[str]:
167        """Build the command line arguments for running ht.
168
169        Args:
170            ht_binary: Optional path to ht binary. If not provided, find_ht_binary() will be called.
171
172        Returns:
173            List of command arguments that would be passed to subprocess.Popen
174        """
175        if ht_binary is None:
176            ht_binary = find_ht_binary()
177
178        cmd_args = [ht_binary]
179
180        # Add subscription arguments
181        if self.subscribes:
182            subscribe_strings = [event.value for event in self.subscribes]
183            cmd_args.extend(["--subscribe", ",".join(subscribe_strings)])
184
185        # Add size arguments if specified
186        if self.rows is not None and self.cols is not None:
187            cmd_args.extend(["--size", f"{self.cols}x{self.rows}"])
188
189        # Add style mode if specified
190        if self.style_mode is not None:
191            cmd_args.extend(["--style-mode", self.style_mode])
192
193        # Add separator and the command to run
194        cmd_args.append("--")
195        if isinstance(self.command, str):
196            cmd_args.extend(self.command.split())
197        else:
198            cmd_args.extend(self.command)
199
200        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):
 53class HtEvent(StrEnum):
 54    """
 55    Event types that can be subscribed to from the ht process.
 56
 57    The original set of events is documented [in the ht repo](https://github.com/andyk/ht?tab=readme-ov-file#events).
 58
 59    Events added by `htty`:
 60
 61    - pid
 62    - exitCode
 63    - debug
 64    - completed
 65    """
 66
 67    INIT = "init"
 68    """
 69    Same as snapshot event (see below) but sent only once, as the first event after ht's start (when sent to
 70    STDOUT) and upon establishing of WebSocket connection.
 71    """
 72
 73    SNAPSHOT = "snapshot"
 74    """
 75    Terminal window snapshot. Sent when the terminal snapshot is taken with the takeSnapshot command.
 76
 77    Event data is an object with the following fields:
 78
 79    - cols - current terminal width, number of columns
 80    - rows - current terminal height, number of rows
 81    - text - plain text snapshot as multi-line string, where each line represents a terminal row
 82    - seq - a raw sequence of characters, which when printed to a blank terminal puts it in the same state as
 83      ht's virtual terminal
 84    """
 85
 86    OUTPUT = "output"
 87    """
 88    Terminal output. Sent when an application (e.g. shell) running under ht prints something to the terminal.
 89
 90    Event data is an object with the following fields:
 91
 92    - seq - a raw sequence of characters written to a terminal, potentially including control sequences
 93      (colors, cursor positioning, etc.)
 94    """
 95
 96    RESIZE = "resize"
 97    """
 98    Terminal resize. Send when the terminal is resized with the resize command.
 99
100    Event data is an object with the following fields:
101
102    - cols - current terminal width, number of columns
103    - rows - current terminal height, number of rows
104    """
105
106    PID = "pid"
107    """
108    ht runs the indicated command in `sh`.
109    This event provides the pid of that `sh` process
110    """
111
112    EXIT_CODE = "exitCode"
113    """
114    htty modified ht to stay open even after the command has completed.
115    This event indicates the exit code of the underlying command.
116    """
117
118    COMMAND_COMPLETED = "commandCompleted"
119    """
120    htty modified ht to run your command like so:
121
122    Previously, ht did the simple thing and ran your command like this:
123    ```
124    sh -c '{command}''
125    ```
126
127    Sometimes, the PTY would shut down before all output was processed by ht, causing snapshots taken
128    after exit to be incomplete.
129    To fix this htty modified ht to run your like so:
130
131    ```
132    sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/a/temp/fifo ; exit $exit_code'
133    ```
134
135    (The fifo is used to notify `ht wait-exit` that it's safe to exit)
136
137    Following this change, the command might complete at one time, and the exit code would be made available later.
138    This event indicates when the command completed, exitCode appears when the shell exits.
139    """
140
141    DEBUG = "debug"
142    """
143    These events contain messages that might be helpful for debugging `ht`.
144    """

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:
203def find_ht_binary() -> str:
204    """Find the bundled ht binary."""
205    # Check HTTY_HT_BIN environment variable first
206    env_path = os.environ.get("HTTY_HT_BIN")
207    if env_path and os.path.isfile(env_path):
208        return env_path
209
210    ht_exe = "ht" + (sysconfig.get_config_var("EXE") or "")
211
212    # First, try to find the binary relative to this package installation
213    pkg_file = __file__  # This file: .../site-packages/htty_core/core.py
214    pkg_dir = os.path.dirname(pkg_file)  # .../site-packages/htty_core/
215    site_packages = os.path.dirname(pkg_dir)  # .../site-packages/
216    python_env = os.path.dirname(site_packages)  # .../lib/python3.x/
217    env_root = os.path.dirname(python_env)  # .../lib/
218    actual_env_root = os.path.dirname(env_root)  # The actual environment root
219
220    # Look for binary in the environment's bin directory
221    env_bin_path = os.path.join(actual_env_root, "bin", ht_exe)
222    if os.path.isfile(env_bin_path):
223        return env_bin_path
224
225    # Only look for the bundled binary - no system fallbacks
226    raise FileNotFoundError(
227        f"Bundled ht binary not found at expected location: {env_bin_path}. "
228        f"This indicates a packaging issue with htty-core."
229    )

Find the bundled ht binary.

def run(args: HtArgs) -> pdoc.extract._PdocDefusedPopen[str]:
232def run(args: HtArgs) -> subprocess.Popen[str]:
233    """
234    Given some `HtArgs` object, run its command via ht.
235
236    `ht` connect
237
238
239    Returns a subprocess.Popen object representing the running ht process.
240    The caller is responsible for managing the process lifecycle.
241    """
242    cmd_args = args.get_command()
243
244    # Start the process
245    return subprocess.Popen(
246        cmd_args,
247        stdin=subprocess.PIPE,
248        stdout=subprocess.PIPE,
249        stderr=subprocess.PIPE,
250        text=True,
251        bufsize=1,
252    )

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)']
class StyleMode(enum.StrEnum):
23class StyleMode(StrEnum):
24    """Style mode for terminal output."""
25
26    PLAIN = "plain"
27    STYLED = "styled"

Style mode for terminal output.

PLAIN = <StyleMode.PLAIN: 'plain'>
STYLED = <StyleMode.STYLED: 'styled'>
__version__ = '0.2.30'