Skip to main content

painted

A semantic renderer for the terminal. You declare what your output means — which fields matter at a glance, which are detail, which facets a reader would ask for by name — and painted derives how to show it: how much detail, what format, styled or plain, and which CLI flags exist at all. One dependency.

What "semantic" means, concretely

Here's a deploy-status tool. The render function declares meaning in three places: each service's state maps to a severity style, per-service latency is a named facet called timings, and error detail belongs to zoom level 2. It never mentions flags, pipes, JSON, or help text.

import sys
from painted import Block, Fidelity, Style, Tag, join_vertical, run_cli

STATE_STYLE = {
    "ok": Style(fg="green"),
    "degraded": Style(fg="yellow"),
    "down": Style(fg="red", bold=True),
}

def fetch() -> dict:
    return {"services": [
        {"name": "api-gateway", "state": "ok", "replicas": "3/3", "ms": 12},
        {"name": "billing", "state": "degraded", "replicas": "2/3", "ms": 340},
        {"name": "search", "state": "ok", "replicas": "2/2", "ms": 28},
    ]}

def renderer(data: dict, fidelity: Fidelity, width: int | None) -> Block:
    rows = []
    for svc in data["services"]:
        line = f"{svc['state']:<9} {svc['name']:<14} {svc['replicas']}"
        if fidelity.shows("timings"):                    # a named facet
            line += f"  {svc['ms']:>4}ms"
        rows.append(Block.text(line, STATE_STYLE[svc["state"]]))
        if fidelity.depth >= 2 and svc["state"] != "ok": # detail level
            rows.append(Block.text("          last error: upstream timeout (2m ago)", Style(dim=True)))
    return join_vertical(*rows)

run_cli(
    sys.argv[1:], renderer=renderer, fetch=fetch,
    prog="deploys", description="Deployment status",
    tags=[Tag("timings", "Show per-service latency", implied_at=2)],
)

That's the whole program. Now watch the rendering decisions derive — every output below is real captured output of the code above.

The default view shows what you declared to matter at a glance (on a TTY, ok is green and degraded is yellow — the severity styles):

$ deploys
ok        api-gateway    3/3
degraded  billing        2/3
ok        search         2/2

-v means zoom 2 — so the error detail you gated appears, and timings switches on because you declared it implied at that depth:

$ deploys -v
ok        api-gateway    3/3    12ms
degraded  billing        2/3   340ms
          last error: upstream timeout (2m ago)
ok        search         2/2    28ms

--timings exists as a flag because the Tag was declared — a reader can ask for that one facet by name without the rest of the verbosity:

$ deploys --timings
ok        api-gateway    3/3    12ms
degraded  billing        2/3   340ms
ok        search         2/2    28ms

The same declaration serializes — renderer isn't even called for this:

$ deploys --json
{"services": [{"name": "api-gateway", "state": "ok", "replicas": "3/3", "ms": 12}, ...]}

A pipe gets plain text automatically — no ANSI garbage in grep:

$ deploys | grep degraded
degraded  billing        2/3

And the help documents the surface you declared — nothing more:

$ deploys -h
deploys

Deployment status

Layers (named facets)
  --timings  Show per-service latency

Zoom (what to show)
  -q, --quiet    Minimal output
  -v, --verbose  Detailed (-v) or full (-vv)

Format (serialization)
  --json   JSON output
  --plain  Plain text, no ANSI codes
...

Nothing was written twice, and none of it can drift: parsing, help, and TAB completion are three reflections of the one declared parser — the flag under -h is the flag that parses is the flag that completes (multi-command apps get the zsh/bash glue from run_app's auto-injected completion command). That's the library's governing rule — the honesty rule: a flag exists only because a capability was declared, and a declared capability must change output. Nothing invented, nothing dead.

Styling toolkits make output beautiful; app frameworks compose widgets. painted's job is the layer between: zoom, format, delivery mode, and the flag surface itself all derive from what you declared the output to mean.

Explore first

Zero declarations also works. paint() is transcription — it renders what a value already declares (dict → key/value, list → items, a dataclass/Enum → its fields) and never invents a shape a value didn't declare (a bare list is items, not a chart; that claim needs lens=chart_lens):

from painted import paint

paint({"cpu": 67, "mem": 82, "disk": 45})

A TTY gets styled key/value lines; a pipe gets plain text. Same data, same function — and it's the same verb every layer up the stack renders through. The transcript is a starting point for poking at data, not the API — when output matters, declare more: compose the Block yourself, or declare the CLI's capabilities and let the surfaces derive.

Enter anywhere

Every entry point uses the same building blocks. Pick the rung that fits your problem — each rung is additive, and climbing never rewrites the rung below. (The invariant is monotonic enhancement: day-one code stays load-bearing forever.)

Print styled output

Replace print() one call at a time. Auto-detects TTY — no ANSI garbage in pipes.

from painted import Block, Style, print_block

block = Block.text("deploy OK", Style(fg="green", bold=True))
print_block(block)

Compose

Blocks are immutable rectangles. Compose them with functions — no widget tree, no DOM.

from painted import border, join_vertical, pad, ROUNDED

header = Block.text(" api-gateway ", Style(bold=True, reverse=True))
status = join_vertical(
    Block.text("  replicas: 2/3 ready", Style(fg="yellow")),
    Block.text("  /health:  200  12ms", Style(fg="green")),
)
card = border(join_vertical(header, status), chars=ROUNDED)
print_block(card)

CLI harness

One render function, three output modes. Pipe gets static, TTY gets live updates, -i gets full interactive.

from painted import run_cli, Fidelity, Block

def renderer(data: dict, fidelity: Fidelity, width: int | None) -> Block:
    # your render logic — returns a Block
    ...

def fetch() -> dict:
    return {"status": "ok", "replicas": 3}

run_cli(sys.argv[1:], renderer=renderer, fetch=fetch)
myapp              # auto-detect
myapp -q           # quiet (zoom 0)
myapp -v           # verbose (zoom 2)
myapp --json       # JSON output
myapp | grep ok    # plain text, no ANSI

The flag surface grows only as you declare — the honesty rule again:

run_cli(
    sys.argv[1:], renderer=renderer, fetch=fetch,
    tags=[Tag("thinking", "Show reasoning", implied_at=3)],  # generates --thinking
    depth_aliases={"brief": 0, "full": 3},                   # --brief / --full
    budgets=True,                                            # --max-chars / --max-lines
)

Gate content with fidelity.shows("thinking"); read fidelity.chars for budgets. Add fetch_stream= for live updates (--live appears), and live_delivery="surface" to upgrade sustained streams to an alt-screen render loop (-i appears, converging with --live). Each rung is additive — climbing never rewrites the rung below. For multi-command apps, run_app routes subcommands through the same harness and injects a completion command that emits the zsh/bash glue. The full consumer guide lives in src/painted/README.md.

Full TUI

Alt screen, keyboard input, async render loop, diff-flush. Subclass Surface, override render() and on_key().

import asyncio
from painted import Block, Style, border
from painted.tui import Surface

class MyApp(Surface):
    def render(self):
        block = Block.text("Hello!", Style(fg="green"))
        border(block, title="Demo").paint(self._buf)

    def on_key(self, key: str):
        if key == "q":
            self.quit()

asyncio.run(MyApp().run())

Install

pip install painted

One dependency: wcwidth (wide character display width).

API

Two stability tiers: painted.core + painted.views + painted.display are semver-stable (removing or renaming a public name is a major version — show()'s removal at 1.0 is that pre-declared event); painted.cli + painted.tui are the evolving framework surface and may change across minor versions — pre-1.0, pin accordingly.

Primitives

Export Purpose
Cell / Style Atomic display unit (char + style, frozen)
Span / Line Styled text with display-width awareness
Block Immutable rectangle of cells for composition

Composition

Export Purpose
join_horizontal / join_vertical Combine Blocks
pad / border / truncate Transform Blocks
BorderChars ROUNDED, HEAVY, DOUBLE, LIGHT, ASCII presets

Display

Export Purpose
paint(data) Zero-config display; transcribes any value onto the surface
print_block(block) Print a Block to stdout (TTY-aware)
run_cli(args, renderer=, fetch=, ...) CLI harness: zoom/mode/format, plus declared tags=, depth_aliases=, budgets=, fetch_stream=, live_delivery=
run_app(argv, commands) Multi-command routing; each AppCommand handler calls run_cli

Views (painted.views)

Export Purpose
shape_lens Auto-dispatch for exploration (numeric → chart, hierarchical → tree)
tree_lens / chart_lens Explicit tree and chart strategies
list_view / table / text_input Stateful interactive components
spinner / progress_bar / sparkline Animation and data viz

TUI (painted.tui)

Export Purpose
Surface Alt screen, keyboard, resize, diff-flush render loop
Layer Modal stack: Stay / Pop / Push / Quit
Buffer / BufferView 2D cell grid with region clipping

Aesthetic

Export Purpose
Palette 5 semantic Style roles (success, warning, error, accent, muted) + a series categorical ramp
IconSet Glyph vocabulary with ASCII fallback

License

MIT

Metadata

Release files for painted 0.15.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for painted 0.15.0
File Size Uploaded
painted-0.15.0.tar.gz 2.1 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for painted 0.15.0
File Interpreter ABI Platform
painted-0.15.0-py3-none-any.whl Python 3 none any Details

Total release size: 2.7 MB

Release files / painted-0.15.0.tar.gz

Download URL painted-0.15.0.tar.gz
Size 2.1 MB
Tags Source
SHA-256 checksum
How to use checksums
04978fcdc9942b74bc99237ff832d8c81fb130be6ad833b82e5059618e2ab595
BLAKE2b-256 checksum
How to use checksums
e8e18770893cc64572fdc4fe2ec9ca59b3ebc3d083b7808600ac34e77df51fd4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / painted-0.15.0-py3-none-any.whl

Download URL painted-0.15.0-py3-none-any.whl
Size 621.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1195b077a0e020c51dbade8d5e22377f3ace56a3ff0b1bca6ba57d11dec0b214
BLAKE2b-256 checksum
How to use checksums
35a738c8a3acb8b070d164abb95a850d94593a80fb17eee4ffc2bc27ec6165f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.15.0 This release

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.1

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.1

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page