Skip to main content

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.

Full documentation

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

  1. terminal("python my_cli.py") spawns the command in a real pseudo-terminal (PTY) via pexpect
  2. A background thread reads PTY output and feeds it through a VT100 emulator (pyte)
  3. get_by_text("...") creates a lazy locator that searches the emulated screen
  4. expect(...).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:

  1. Scrollback buffer (default): get_by_text() searches the full buffer including content scrolled off-screen. This is automatic -- no configuration needed.

  2. 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

Runtime: pexpect, pyte

Documentation

Full documentation at thekevinscott.github.io/curtaincall.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

curtaincall-0.2.0.tar.gz (67.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

curtaincall-0.2.0-py3-none-any.whl (20.8 kB view details)

Uploaded Python 3

File details

Details for the file curtaincall-0.2.0.tar.gz.

File metadata

  • Download URL: curtaincall-0.2.0.tar.gz
  • Upload date:
  • Size: 67.5 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

Hashes for curtaincall-0.2.0.tar.gz
Algorithm Hash digest
SHA256 7c63c32e018235a0ac908f1baae78d1f55d48f51f0868ac3ea7789c4869dc60d
MD5 2b8cc4a75ccae1360a9a73396449bd69
BLAKE2b-256 9190c67430f4abd9dfdfc11103ea89aef303e4a787d2a4111451e478bfbcf9ee

See more details on using hashes here.

File details

Details for the file curtaincall-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: curtaincall-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 20.8 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

Hashes for curtaincall-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 40d3f5a2fe38063b96982c7b598ad3e0e8450dd48da7ed576d8e1e2332f11d59
MD5 e5d6c61e6c9056dc56fc89406be6014e
BLAKE2b-256 dc747227d5a32e7c705c197cdd6afe6a3815427c580293a4509f607a002fffc0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page