Skip to content

Commit 0bdc8a5

Browse files
more docs tweaks
1 parent 1313211 commit 0bdc8a5

10 files changed

Lines changed: 1251 additions & 1297 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![CI](https://github.com/MatrixManAtYrService/htty/workflows/Test/badge.svg)](https://github.com/MatrixManAtYrService/htty/actions/workflows/tests.yml)
44
[![PyPI htty](https://img.shields.io/pypi/v/htty.svg)](https://pypi.org/project/htty/)
55
[![PyPI python versions](https://img.shields.io/pypi/pyversions/htty.svg)](https://pypi.org/project/htty/)
6-
[docs](https://img.shields.io/badge/docs)
6+
[![docs](https://img.shields.io/badge/docs)](https://matrixmanatyrservice.github.io/htty/htty.html)
77

88
`htty` controls processes that are attached to a headless terminal.
99
It has both a command line interface, and a Python API.

docs/htty.html

Lines changed: 1061 additions & 944 deletions
Large diffs are not rendered by default.

docs/search.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

htty-core/src/rust/constants.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ pub const DEFAULT_SNAPSHOT_TIMEOUT: Duration = Duration::from_millis(5000);
7575
pub const DEFAULT_EXIT_TIMEOUT: Duration = Duration::from_millis(5000);
7676
pub const DEFAULT_GRACEFUL_TERMINATION_TIMEOUT: Duration = Duration::from_millis(5000);
7777
pub const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_millis(5000);
78-
pub const SNAPSHOT_RETRY_TIMEOUT: Duration = Duration::from_millis(500);
78+
pub const SNAPSHOT_RETRY_TIMEOUT: Duration = Duration::from_millis(100);
7979
pub const SUBSCRIPTION_TIMEOUT: Duration = Duration::from_millis(5000);
8080
pub const EMPTINESS_CHECK_INTERVAL: Duration = Duration::from_millis(10);
8181
pub const FIFO_MONITORING_INTERVAL: Duration = Duration::from_millis(50);

htty/pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ dependencies = [
3535
# [[[cog
3636
# import os
3737
# version = os.environ["HTTY_VERSION"]
38-
#
38+
#
3939
# # Use local dependency for development/version-bump, PyPI for releases
4040
# use_local = os.environ.get("HTTY_USE_LOCAL_CORE", "false").lower() == "true"
41-
#
41+
#
4242
# if use_local:
4343
# cog.out(f'"htty_core @ {{root:uri}}/../htty-core",')
4444
# else:
@@ -47,6 +47,7 @@ dependencies = [
4747
"htty-core==0.2.25",
4848
# [[[end]]]
4949
"ansi2html",
50+
"tenacity>=9.1.2",
5051
]
5152

5253
[project.urls]
@@ -74,4 +75,4 @@ analysis = [
7475
allow-direct-references = true
7576

7677
[tool.pdoc]
77-
sort_identifiers = false
78+
sort_identifiers = false

htty/src/htty/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
DEFAULT_SNAPSHOT_TIMEOUT = 5.0
4848
DEFAULT_EXIT_TIMEOUT = 5.0
4949
DEFAULT_GRACEFUL_TERMINATION_TIMEOUT = 5.0
50-
SNAPSHOT_RETRY_TIMEOUT = 0.5
50+
SNAPSHOT_RETRY_TIMEOUT = 0.1
5151
SUBPROCESS_EXIT_DETECTION_DELAY = 0.2
5252
DEFAULT_EXPECT_TIMEOUT = 5.0
5353
# [[[end]]]

htty/src/htty/ht.py

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919
Rows,
2020
run as htty_core_run,
2121
)
22+
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_fixed
2223

2324
from .constants import (
2425
DEFAULT_EXIT_TIMEOUT,
2526
DEFAULT_EXPECT_TIMEOUT,
2627
DEFAULT_SLEEP_AFTER_KEYS,
2728
DEFAULT_SNAPSHOT_TIMEOUT,
2829
DEFAULT_SUBPROCESS_WAIT_TIMEOUT,
29-
MAX_SNAPSHOT_RETRIES,
3030
SNAPSHOT_RETRY_TIMEOUT,
3131
)
3232
from .html_utils import simple_ansi_to_html
@@ -37,6 +37,12 @@
3737
default_logger = logging.getLogger(__name__)
3838

3939

40+
class SnapshotNotReady(Exception):
41+
"""Exception raised when snapshot is not yet available from the queue."""
42+
43+
pass
44+
45+
4046
__all__ = [
4147
"terminal_session",
4248
"SnapshotResult",
@@ -76,6 +82,17 @@ class HtWrapper:
7682
methods for interacting with the process and capturing its output.
7783
"""
7884

85+
ht: ProcessController
86+
"""
87+
Helpers for interacting with the `ht` process (a child process of the python
88+
that called `htty.run` or `htty.terminal_session`)
89+
"""
90+
91+
cmd: ProcessController
92+
"""
93+
Helpers for interacting with the shell that wraps the caller's command (`ht`'s child process)
94+
"""
95+
7996
def __init__(
8097
self,
8198
ht_proc: "subprocess.Popen[str]",
@@ -127,7 +144,8 @@ def __del__(self):
127144
self._ht_proc.terminate()
128145

129146
def get_output(self) -> list[dict[str, Any]]:
130-
"""Return list of output events."""
147+
"""
148+
Return list of [output](./htty-core/htty_core.html#HtEvent.OUTPUT) events."""
131149
return [event for event in self._output_events if event.get("type") == "output"]
132150

133151
def add_output_event(self, event: dict[str, Any]) -> None:
@@ -154,9 +172,43 @@ def set_subprocess_completed(self, completed: bool) -> None:
154172

155173
def send_keys(self, keys: Union[KeyInput, list[KeyInput]]) -> None:
156174
"""
157-
Send keys to the terminal.
175+
Send keys to the terminal. Accepts strings, `Press` objects, and lists of strings or `Press` objects.
176+
For keys that you can `Press`, see
177+
[keys.py](https://github.com/MatrixManAtYrService/htty/blob/main/htty/src/htty/keys.py).
178+
179+
```python
180+
from htty import Press, terminal_session
181+
182+
with (
183+
terminal_session("sh -i", rows=4, cols=40, logger=test_logger) as sh,
184+
):
185+
sh.send_keys("echo foo && sleep 999")
186+
sh.send_keys(Press.ENTER)
187+
sh.expect("^foo")
188+
sh.send_keys(Press.CTRL_Z)
189+
sh.expect("Stopped")
190+
sh.send_keys(["clear", Press.ENTER])
191+
```
158192
159-
Since we use --wait-for-output, this is much more reliable than the original.
193+
These are sent to `ht` as events that look like this
194+
195+
```json
196+
{"type": "sendKeys", "keys": ["echo foo && sleep 999", "Enter"]}
197+
```
198+
199+
Notice that Press.ENTER is still sent as "Enter" under the hood.
200+
`ht` checks to see if it corresponds with a known key and sends it letter-at-a-time if not.
201+
202+
Because of this, you might run into suprises if you want to type individual characters which happen to spell out
203+
a known key such as "Enter" or "Backspace".
204+
205+
Work around this by breaking up the key names like so:
206+
207+
```python
208+
sh.send_keys(["Ente", "r"])
209+
```
210+
211+
If this behavior is problematic for you, consider submitting an issue.
160212
"""
161213
key_strings = keys_to_strings(keys)
162214
message = json.dumps({"type": "sendKeys", "keys": key_strings})
@@ -204,14 +256,24 @@ def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult:
204256

205257
time.sleep(DEFAULT_SLEEP_AFTER_KEYS)
206258

207-
# Process events until we find the snapshot
208-
retry_count = 0
209-
while retry_count < MAX_SNAPSHOT_RETRIES:
259+
# Use tenacity to retry getting the snapshot
260+
return self._wait_for_snapshot(timeout)
261+
262+
def _wait_for_snapshot(self, timeout: float) -> SnapshotResult:
263+
"""
264+
Wait for snapshot response from the event queue with timeout and retries.
265+
"""
266+
267+
@retry(
268+
stop=stop_after_delay(timeout),
269+
wait=wait_fixed(SNAPSHOT_RETRY_TIMEOUT),
270+
retry=retry_if_exception_type(SnapshotNotReady),
271+
)
272+
def _get_snapshot() -> SnapshotResult:
210273
try:
211274
event = self._event_queue.get(block=True, timeout=SNAPSHOT_RETRY_TIMEOUT)
212-
except queue.Empty:
213-
retry_count += 1
214-
continue
275+
except queue.Empty as e:
276+
raise SnapshotNotReady("No events available in queue") from e
215277

216278
if event["type"] == "snapshot":
217279
data = event["data"]
@@ -240,10 +302,21 @@ def snapshot(self, timeout: float = DEFAULT_SNAPSHOT_TIMEOUT) -> SnapshotResult:
240302
# Put non-snapshot events back in queue for reader thread to handle
241303
self._event_queue.put(event)
242304

243-
raise RuntimeError(
244-
f"Failed to receive snapshot event after {MAX_SNAPSHOT_RETRIES} attempts. "
245-
f"ht process may have exited or stopped responding."
246-
)
305+
# If we get here, we didn't find a snapshot, so retry
306+
raise SnapshotNotReady(f"Received {event['type']} event, waiting for snapshot")
307+
308+
try:
309+
return _get_snapshot()
310+
except Exception as e:
311+
# Handle both direct SnapshotNotReady and tenacity RetryError
312+
from tenacity import RetryError
313+
314+
if isinstance(e, (SnapshotNotReady, RetryError)):
315+
raise RuntimeError(
316+
f"Failed to receive snapshot event within {timeout} seconds. "
317+
f"ht process may have exited or stopped responding."
318+
) from e
319+
raise
247320

248321
def exit(self, timeout: float = DEFAULT_EXIT_TIMEOUT) -> int:
249322
"""
@@ -400,7 +473,7 @@ def expect(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -> None:
400473

401474
# Compile the regex pattern
402475
try:
403-
regex = re.compile(pattern)
476+
regex = re.compile(pattern, re.MULTILINE)
404477
except re.error as e:
405478
raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
406479

@@ -473,7 +546,7 @@ def expect_absent(self, pattern: str, timeout: float = DEFAULT_EXPECT_TIMEOUT) -
473546

474547
# Compile the regex pattern
475548
try:
476-
regex = re.compile(pattern)
549+
regex = re.compile(pattern, re.MULTILINE)
477550
except re.error as e:
478551
raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
479552

@@ -531,7 +604,7 @@ def terminal_session(
531604
```
532605
533606
Its usage is otherwise the same as `run`.
534-
It also returns a
607+
Like `run` it returns a `HtWrapper`.
535608
536609
"""
537610

@@ -590,15 +663,6 @@ def run(
590663
└── vim
591664
```
592665
593-
For reasons that are documented in [htty-core](./htty-core/htty_core.html#run), the command that ht
594-
runs is not:
595-
596-
sh -c '{command}'
597-
598-
Instead it's something like this:
599-
600-
sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/tmp/ht_fifo_5432 ; exit $exit_code'
601-
602666
This function invokes `ht` as a subprocess such that you end up with a process tree like the one shown
603667
above. It returns an `HtWrapper` object which can be used to interact with ht and its child process.
604668
@@ -608,9 +672,24 @@ def run(
608672
proc = run("some command")
609673
# do stuff
610674
proc.exit()
611-
````
675+
```
676+
612677
If you'd rather not risk having a bunch of `ht` processes lying around and wasting CPU cycles,
613678
consider using the `terminal_session` instead.
679+
680+
For reasons that are documented in
681+
[htty-core](https://matrixmanatyrservice.github.io/htty/htty-core/htty_core.html#HtEvent.COMMAND_COMPLETED), the
682+
command that ht runs is not:
683+
684+
sh -c '{command}'
685+
686+
Instead it's something like this:
687+
688+
sh -c '{command} ; exit_code=$? ; /path/to/ht wait-exit /path/to/tmp/ht_fifo_5432 ; exit $exit_code'
689+
690+
Because of this, it's possible to come up with command strings that cause sh to behave in problematic ways (for
691+
example: `'`). For now the mitigation for this is: "don't do that." (If you'd like me to prioritize changing this
692+
please leave a comment in https://github.com/MatrixManAtYrService/htty/issues/2)
614693
"""
615694
# Use provided logger or fall back to default
616695
process_logger = logger or default_logger

0 commit comments

Comments
 (0)