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, Style, Tag, join_vertical, run_cli
from painted.cli import CliContext
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 render(ctx: CliContext, data: dict) -> Block:
rows = []
for svc in data["services"]:
line = f"{svc['state']:<9} {svc['name']:<14} {svc['replicas']}"
if ctx.fidelity.shows("timings"): # a named facet
line += f" {svc['ms']:>4}ms"
rows.append(Block.text(line, STATE_STYLE[svc["state"]]))
if ctx.zoom >= 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:], render=render, 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 — render 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, CliContext, Block
def render(ctx: CliContext, data: dict) -> Block:
# your render logic — returns a Block
...
def fetch() -> dict:
return {"status": "ok", "replicas": 3}
run_cli(sys.argv[1:], render=render, 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:], render=render, 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 ctx.fidelity.shows("thinking"); read ctx.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, render=, 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
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 painted-0.10.0.tar.gz.
File metadata
- Download URL: painted-0.10.0.tar.gz
- Upload date:
- Size: 1.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2e7494d52923e1afb508c9b20a753bf2e50fac690469c58597e182243588ab18
|
|
| MD5 |
7739a61e97347c12aaef728ec5eb48b0
|
|
| BLAKE2b-256 |
57cd7f1b3635bc6b3c071b1a09698e3a9772fd4a1585196298d78f24116b2fd9
|
Provenance
The following attestation bundles were made for painted-0.10.0.tar.gz:
Publisher:
release.yml on kgruel/painted
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
painted-0.10.0.tar.gz -
Subject digest:
2e7494d52923e1afb508c9b20a753bf2e50fac690469c58597e182243588ab18 - Sigstore transparency entry: 2145312140
- Sigstore integration time:
-
Permalink:
kgruel/painted@4437f5462a9072e8db0d520ddfbc6284738a29a5 -
Branch / Tag:
refs/tags/v0.10.0 - Owner: https://github.com/kgruel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4437f5462a9072e8db0d520ddfbc6284738a29a5 -
Trigger Event:
release
-
Statement type:
File details
Details for the file painted-0.10.0-py3-none-any.whl.
File metadata
- Download URL: painted-0.10.0-py3-none-any.whl
- Upload date:
- Size: 471.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d0dc8eb988be84cf7d4e99896f85e21fa22b816085645b7b7bda17b21578222a
|
|
| MD5 |
6d173122a1a1e22403f95f28bbecce40
|
|
| BLAKE2b-256 |
9994bae36abd1688d356a40511e0c6fdc5492b7c71533d7d5ace0955c2fa071e
|
Provenance
The following attestation bundles were made for painted-0.10.0-py3-none-any.whl:
Publisher:
release.yml on kgruel/painted
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
painted-0.10.0-py3-none-any.whl -
Subject digest:
d0dc8eb988be84cf7d4e99896f85e21fa22b816085645b7b7bda17b21578222a - Sigstore transparency entry: 2145312156
- Sigstore integration time:
-
Permalink:
kgruel/painted@4437f5462a9072e8db0d520ddfbc6284738a29a5 -
Branch / Tag:
refs/tags/v0.10.0 - Owner: https://github.com/kgruel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4437f5462a9072e8db0d520ddfbc6284738a29a5 -
Trigger Event:
release
-
Statement type: