Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ All derive from `TuiTestError`. `wait_*` and `expect_*` raise `ExpectationError`

## API

`TuiTest(session="default", *, timeouts=None, profile=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title`, `screenshot`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`.
`TuiTest(session="default", *, timeouts=None, profile=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `find_text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title`, `screenshot`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`.

`find_text()` returns typed zero-based row/column spans and supports normalized
whitespace, `after` / `before` anchors, and any/unique/first/last/nth
occurrences. `expect_text()` accepts the same selector options plus `TextStyle`
checks for colors, bold, dim, italic, underline, inverse, hidden,
strikethrough, and blink.

Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`.

Expand Down
1 change: 1 addition & 0 deletions bindings/python/native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ anyhow.workspace = true
tui-test.workspace = true
pyo3 = { version = "0.28" }
pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }

[features]
Expand Down
82 changes: 81 additions & 1 deletion bindings/python/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use tui_test::shell::Shell;
use tui_test::{
Cell, CellColor, Cursor, ErrorKind, MouseAction, OpenOptions, OpenResult, Operation,
OperationResult, PackedScreen, RunOptions, ScreenshotResult, Size, SnapshotResult, State,
Timeouts, TuiTestError,
TextMatch, TextSelector, TextStyle, Timeouts, TuiTestError,
};

pyo3::create_exception!(
Expand Down Expand Up @@ -219,6 +219,23 @@ impl NativeSession {
)
}

fn find_text<'py>(
&self,
py: Python<'py>,
selector_json: String,
) -> PyResult<Bound<'py, PyAny>> {
let name = self.name.clone();
future_blocking(
py,
move || {
let selector: TextSelector = serde_json::from_str(&selector_json)
.map_err(|error| TuiTestError::usage(error.to_string()))?;
execute_matches(&name, Operation::FindText { selector })
},
matches_to_py,
)
}

fn packed_screen<'py>(&self, py: Python<'py>, full: bool) -> PyResult<Bound<'py, PyAny>> {
let name = self.name.clone();
future_blocking(
Expand Down Expand Up @@ -807,6 +824,35 @@ impl NativeSession {
)
}

#[pyo3(signature = (request_json, timeout_ms))]
fn expect_text_selector<'py>(
&self,
py: Python<'py>,
request_json: String,
timeout_ms: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
let timeout_ms = capture_optional_integer(timeout_ms);
let name = self.name.clone();
future_blocking(
py,
move || {
let (selector, style, not): (TextSelector, TextStyle, bool) =
serde_json::from_str(&request_json)
.map_err(|error| TuiTestError::usage(error.to_string()))?;
execute_unit(
&name,
Operation::ExpectTextSelector {
selector,
not,
style,
timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?,
},
)
},
unit_to_py,
)
}

#[pyo3(signature = (code, timeout_ms))]
fn expect_exit_code<'py>(
&self,
Expand Down Expand Up @@ -1187,6 +1233,13 @@ fn execute_cells(name: &str, operation: Operation) -> Result<Vec<Cell>, TuiTestE
}
}

fn execute_matches(name: &str, operation: Operation) -> Result<Vec<TextMatch>, TuiTestError> {
match global_registry().execute(name, operation)? {
OperationResult::Matches(value) => Ok(value),
_ => Err(unexpected_result("text matches")),
}
}

fn execute_command(name: &str, operation: Operation) -> Result<Option<String>, TuiTestError> {
match global_registry().execute(name, operation)? {
OperationResult::Command(value) => Ok(value),
Expand Down Expand Up @@ -1354,6 +1407,33 @@ fn cells_to_py(py: Python<'_>, cells: Vec<Cell>) -> PyResult<Py<PyAny>> {
Ok(values.into_any().unbind())
}

fn matches_to_py(py: Python<'_>, matches: Vec<TextMatch>) -> PyResult<Py<PyAny>> {
let values = PyList::empty(py);
for matched in matches {
let value = PyDict::new(py);
value.set_item("text", matched.text)?;
let start = PyDict::new(py);
start.set_item("row", matched.start.row)?;
start.set_item("column", matched.start.column)?;
value.set_item("start", start)?;
let end = PyDict::new(py);
end.set_item("row", matched.end.row)?;
end.set_item("column", matched.end.column)?;
value.set_item("end", end)?;
let spans = PyList::empty(py);
for span in matched.spans {
let item = PyDict::new(py);
item.set_item("row", span.row)?;
item.set_item("start", span.start)?;
item.set_item("end", span.end)?;
spans.append(item)?;
}
value.set_item("spans", spans)?;
values.append(value)?;
}
Ok(values.into_any().unbind())
}

fn cursor_to_py(py: Python<'_>, cursor: Cursor) -> PyResult<Py<PyAny>> {
Ok(cursor_dict(py, cursor)?.into_any().unbind())
}
Expand Down
20 changes: 19 additions & 1 deletion bindings/python/src/tui_test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,19 @@
TerminalArtifact,
UsageError,
)
from .types import Cell, Colors, Profile, State, Timeouts
from .types import (
Cell,
Colors,
Profile,
State,
TextAnchor,
TextMatch,
TextOccurrence,
TextPosition,
TextSpan,
TextStyle,
Timeouts,
)

__all__ = [
"TuiTest",
Expand All @@ -29,6 +41,12 @@
"Colors",
"Profile",
"State",
"TextAnchor",
"TextMatch",
"TextOccurrence",
"TextPosition",
"TextSpan",
"TextStyle",
"Timeouts",
"__version__",
]
2 changes: 2 additions & 0 deletions bindings/python/src/tui_test/_native.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class NativeSession:
def close(self) -> typing.Awaitable[None]: ...
def state(self) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ...
def text(self, full: bool) -> typing.Awaitable[str]: ...
def find_text(self, selector_json: str) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ...
def packed_screen(self, full: bool) -> typing.Awaitable[typing.Tuple[memoryview, int, int]]:
r"""
Return immutable UTF-8 logical rows plus cell dimensions.
Expand Down Expand Up @@ -76,6 +77,7 @@ class NativeSession:
def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ...
def wait_ready(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ...
def expect_text(self, text: str, regex: bool, full: bool, strict: bool, not_: bool, fg: typing.Optional[str], bg: typing.Optional[str], timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ...
def expect_text_selector(self, request_json: str, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ...
def expect_title(self, text: str, regex: bool, not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ...
def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ...
def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ...
Expand Down
110 changes: 101 additions & 9 deletions bindings/python/src/tui_test/client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import atexit
import json
import os
import time
from dataclasses import asdict
from typing import (
Any,
Awaitable,
Expand All @@ -27,7 +29,16 @@
TerminalArtifact,
UsageError,
)
from .types import Cell, Profile, State, Timeouts
from .types import (
Cell,
Profile,
State,
TextAnchor,
TextMatch,
TextOccurrence,
TextStyle,
Timeouts,
)

_TERMINAL_MARKER = "Terminal content:\n"
_TIMEOUT_CLASSES = ("text", "idle", "command", "exit", "ready")
Expand Down Expand Up @@ -79,6 +90,45 @@ def _profile_values(
return normalized.get("scrollback"), list(colors.items())


def _occurrence_value(value: TextOccurrence) -> object:
if isinstance(value, int) and not isinstance(value, bool):
return {"nth": value}
return value


def _anchor_value(anchor: Optional[TextAnchor]) -> Optional[Dict[str, object]]:
if anchor is None:
return None
return {
"text": anchor.text,
"regex": anchor.regex,
"occurrence": _occurrence_value(anchor.occurrence),
}


def _selector_value(
text: str,
*,
regex: bool,
full: bool,
whitespace: str,
after: Optional[TextAnchor],
before: Optional[TextAnchor],
occurrence: TextOccurrence,
) -> Dict[str, object]:
return {
"text": text,
"regex": regex,
"full": full,
"whitespace": whitespace,
"scope": {
"after": _anchor_value(after),
"before": _anchor_value(before),
},
"occurrence": _occurrence_value(occurrence),
}


def _extract_terminal_text(message: Optional[str]) -> Optional[str]:
if not message:
return None
Expand Down Expand Up @@ -326,6 +376,31 @@ async def state(self) -> State:
async def text(self, *, full: bool = False) -> str:
return await self._await(self._native.text(full))

async def find_text(
self,
text: str,
*,
regex: bool = False,
full: bool = False,
whitespace: str = "exact",
after: Optional[TextAnchor] = None,
before: Optional[TextAnchor] = None,
occurrence: TextOccurrence = "any",
) -> List[TextMatch]:
selector = _selector_value(
text,
regex=regex,
full=full,
whitespace=whitespace,
after=after,
before=before,
occurrence=occurrence,
)
values = await self._guarded(
"find_text", self._native.find_text(json.dumps(selector))
)
return [TextMatch.from_dict(value) for value in values]

async def _packed_screen(
self, *, full: bool = False
) -> Tuple[memoryview, int, int]:
Expand Down Expand Up @@ -439,21 +514,38 @@ async def expect_text(
regex: bool = False,
full: bool = False,
strict: bool = True,
whitespace: str = "exact",
after: Optional[TextAnchor] = None,
before: Optional[TextAnchor] = None,
occurrence: Optional[TextOccurrence] = None,
not_: bool = False,
fg: Optional[str] = None,
bg: Optional[str] = None,
style: Optional[TextStyle] = None,
timeout: Optional[int] = None,
) -> None:
selector = _selector_value(
text,
regex=regex,
full=full,
whitespace=whitespace,
after=after,
before=before,
occurrence=(
occurrence
if occurrence is not None
else ("unique" if strict else "first")
),
)
style_value = asdict(style or TextStyle())
if style_value["foreground"] is None:
style_value["foreground"] = fg
if style_value["background"] is None:
style_value["background"] = bg
await self._guarded(
"expect_text",
self._native.expect_text(
text,
regex,
full,
strict,
not_,
fg,
bg,
self._native.expect_text_selector(
json.dumps([selector, style_value, not_]),
self._timeout("text", timeout),
),
)
Expand Down
Loading