Testing library for terminal applications
Project description
Curtaincall
Testing library for terminal applications.
Curtaincall is a pytest plugin for testing terminal (TUI) applications. It spawns real PTY sessions, emulates a VT100 terminal, and provides an expressive assertion API with auto-waiting locators. Inspired by Microsoft's tui-test.
Installation
pip install curtaincall
The terminal fixture is automatically available when curtaincall is installed -- no imports or configuration needed.
Quick Example
from curtaincall import expect
def test_git_help(terminal):
term = terminal("git --help")
expect(term.get_by_text("usage: git")).to_be_visible()
def test_interactive_cli(terminal):
term = terminal("python my_cli.py")
term.submit("hello")
expect(term.get_by_text("Hello!")).to_be_visible()
How It Works
terminal("python my_cli.py")spawns the command in a real pseudo-terminal (PTY) via pexpect- A background thread reads PTY output and feeds it through a VT100 emulator (pyte)
get_by_text("...")creates a lazy locator that searches the emulated screenexpect(...).to_be_visible()polls the screen until the text appears or times out
Tests automatically wait for output to appear -- no time.sleep() needed.
Features
Auto-waiting Assertions
Every assertion polls the terminal screen with a configurable timeout (default 5 seconds). When an assertion times out, the error includes the current screen content for debugging.
from curtaincall import expect
# Wait for text to appear
expect(term.get_by_text("Ready")).to_be_visible()
# Wait for text to disappear (spinner finished, loading complete)
expect(term.get_by_text("Loading")).not_to_be_visible()
# Custom timeout for slow operations
expect(term.get_by_text("Done")).to_be_visible(timeout=30.0)
Failure output:
AssertionError: Expected text to be visible: 'MISSING'
Screen content:
$ python my_app.py
Hello, World!
$
Locators
Locators find text on the terminal screen. They are lazy -- the screen isn't searched until the locator is used.
import re
# Substring match (default)
term.get_by_text("Hello")
# Full line match (stripped line must equal the text exactly)
term.get_by_text("Hello, World!", full=True)
# Regex match
term.get_by_text(re.compile(r"version \d+\.\d+"))
# Regex with full line match
term.get_by_text(re.compile(r"Hello, \w+!"), full=True)
Locator properties:
locator = term.get_by_text("Hello")
locator.is_visible() # bool -- instant check, no waiting
locator.cells # list[CellMatch] -- matched cell positions
locator.text() # str -- the matched text content
Color Assertions
Verify foreground and background colors of terminal text. Supports standard terminal color names.
expect(term.get_by_text("ERROR")).to_have_fg_color("red")
expect(term.get_by_text("OK")).to_have_fg_color("green")
expect(term.get_by_text("HIGHLIGHT")).to_have_bg_color("blue")
Supported colors: red, green, blue, yellow, cyan, magenta, white, black, default.
Text Content Assertions
expect(term.get_by_text("Hello, World!")).to_contain_text("World")
Keyboard Input
Send text, arrow keys, and control sequences to the terminal.
# Text input
term.write("raw text") # send raw text
term.submit("text + enter") # send text followed by Enter
# Arrow keys
term.key_up()
term.key_down()
term.key_left()
term.key_right()
# Special keys
term.key_enter()
term.key_backspace()
term.key_delete()
term.key_tab()
term.key_escape()
# Control keys
term.key_ctrl_c() # send SIGINT
term.key_ctrl_d() # send EOF
Terminal Resize
Test SIGWINCH handling by resizing the PTY mid-test. Both the PTY and the internal VT100 emulator are resized together.
def test_resize(terminal):
term = terminal("python my_app.py", rows=24, cols=80)
expect(term.get_by_text("80x24")).to_be_visible()
term.set_size(rows=40, cols=120)
expect(term.get_by_text("120x40")).to_be_visible()
Snapshot Testing
Serialize the terminal screen as a box-drawn string for snapshot regression testing.
def test_table_output(terminal):
term = terminal("python my_app.py", rows=10, cols=40)
expect(term.get_by_text("Results")).to_be_visible()
snapshot = term.to_snapshot()
Output format:
╭──────────────────────────────────────╮
│$ python my_app.py │
│Results │
│ │
╰──────────────────────────────────────╯
Pair with syrupy for automatic snapshot management:
def test_table_output(terminal, snapshot):
term = terminal("python my_app.py", rows=10, cols=40)
expect(term.get_by_text("Results")).to_be_visible()
assert term.to_snapshot() == snapshot
Update snapshots with pytest --snapshot-update.
Scrollback Buffer
Locators search the full buffer -- both the visible viewport and lines that have scrolled off the top. This is important when stderr warnings or verbose output push your content off-screen.
# Even if warnings fill the viewport, stdout content is found in scrollback
term = terminal("python -m my_module --help", rows=10, cols=80)
expect(term.get_by_text("Usage:")).to_be_visible() # searches scrollback too
Control scrollback depth with the history parameter (default 1000 lines):
term = terminal("my_command", history=5000) # large scrollback
Screen Inspection
# Full buffer (scrollback + viewport) as 2D list of characters
buffer = term.get_buffer() # list[list[str]]
# Visible viewport only (no scrollback)
viewable = term.get_viewable_buffer()
# Cursor position
cursor = term.get_cursor() # CursorPosition(x=0, y=5)
The terminal Fixture
The terminal fixture is a factory function that creates isolated PTY sessions.
def test_example(terminal):
# Default: 30 rows, 80 columns
term = terminal("python my_app.py")
# Custom dimensions
term = terminal("python my_app.py", rows=24, cols=120)
# Custom environment variables
term = terminal("python my_app.py", env={"DEBUG": "1"})
| Parameter | Type | Default | Description |
|---|---|---|---|
command |
str |
required | Shell command to run |
rows |
int |
30 |
Terminal height |
cols |
int |
80 |
Terminal width |
env |
dict |
None |
Extra environment variables |
history |
int |
1000 |
Scrollback buffer depth (lines) |
suppress_stderr |
bool |
False |
Redirect stderr to /dev/null |
Multiple Terminals
Create multiple terminals in a single test:
def test_client_server(terminal):
server = terminal("python server.py")
client = terminal("python client.py")
expect(server.get_by_text("Listening")).to_be_visible()
expect(client.get_by_text("Connected")).to_be_visible()
Cleanup
All terminals are automatically killed when the test ends. Long-running processes are force-terminated.
Example: Menu Navigation
from curtaincall import expect
def test_arrow_menu(terminal):
term = terminal("python menu.py")
expect(term.get_by_text("Select an option:")).to_be_visible()
term.key_down()
term.key_down()
term.key_enter()
expect(term.get_by_text("Option C")).to_be_visible()
Example: Signal Handling
from curtaincall import expect
def test_ctrl_c(terminal):
term = terminal("python server.py")
expect(term.get_by_text("Running")).to_be_visible()
term.key_ctrl_c()
expect(term.get_by_text("Cleanup complete")).to_be_visible()
Stderr and PTY Behavior
PTYs merge stdout and stderr into a single stream. This means warnings or log messages on stderr share the terminal screen with your application's stdout. If stderr is verbose, it can push stdout content off the visible viewport.
Curtaincall handles this in two ways:
-
Scrollback buffer (default):
get_by_text()searches the full buffer including content scrolled off-screen. This is automatic -- no configuration needed. -
suppress_stderr: When you don't need stderr at all, suppress it entirely:
term = terminal("python -m my_module --help", suppress_stderr=True)
expect(term.get_by_text("Usage:")).to_be_visible()
This wraps the command in bash -c '... 2>/dev/null', redirecting stderr before it reaches the PTY.
Recommendation: Prefer using installed entry points (my-tool --help) over python -m invocation when possible, as the latter is more likely to produce import warnings.
Requirements
- Python 3.12+
- Linux or macOS (requires Unix PTYs)
Dependencies
Documentation
Full documentation at thekevinscott.github.io/curtaincall.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file curtaincall-0.3.0.tar.gz.
File metadata
- Download URL: curtaincall-0.3.0.tar.gz
- Upload date:
- Size: 68.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
afc9e9ce00a256982d69a737976527d38217082ca79fcafcf381fa2cd2bba041
|
|
| MD5 |
7e0fa082b2401944eb4debc4909523ee
|
|
| BLAKE2b-256 |
ca12056a3c89fcbc135b15a7cef872472a73bdad3594ddfccbe11b9a028b2f5f
|
File details
Details for the file curtaincall-0.3.0-py3-none-any.whl.
File metadata
- Download URL: curtaincall-0.3.0-py3-none-any.whl
- Upload date:
- Size: 21.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4a754fa4a8ad726a00ec24ebb545f8f5a34a0f8d159936845d125bfeb2dfb652
|
|
| MD5 |
01bb030b540d131db3606b280f198c30
|
|
| BLAKE2b-256 |
f8521092ca83246b88a880f2fe35e38919b9e069c0ce97708880c4234ab72c32
|