1919 Rows ,
2020 run as htty_core_run ,
2121)
22+ from tenacity import retry , retry_if_exception_type , stop_after_delay , wait_fixed
2223
2324from .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)
3232from .html_utils import simple_ansi_to_html
3737default_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