Skip to content

About

UI primitive definitions for the Astral ecosystem

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Astral Primitives

mismatched concrete subclass discriminators raise ValidationError; valid wire round trips remain unchanged.

Composable, serializable UI primitives for Python. Describe UI as plain Python objects, then serialize them to a dict/JSON for storage or for a server-driven UI to render.

Install

pip install -e ".[dev]"   # editable install with test deps

Quick start

from astralprims import Button

button = Button(
    label="the button text",
    action="open",
    css={"background-color": "white", "color": "#000000"},
)

button.to_dict()
# {
#   "type": "button",
#   "css": {"background-color": "white", "color": "#000000"},
#   "label": "the button text",
#   "action": "open",
#   "payload": {},
#   "variant": "primary",
# }

button.to_json()  # -> JSON string

None and empty css are dropped from the output, so payloads stay clean.

Composing layouts

from astralprims import Card, Container, Text, Button

page = Container(css={"display": "flex"}, direction="column").add(
    Text(content="Welcome", variant="h1"),
    Card(title="Sign up").add(
        Text(content="Enter your details below."),
        Button(label="Get started", action="signup"),
    ),
)
page.to_dict()  # children/content serialize recursively

Round-tripping from data

Already have a primitive as a dict (from storage or an API)? Rebuild it — including the full nested tree:

from astralprims import Primitive

spec = {"type": "button", "label": "Buy", "action": "checkout"}
button = Primitive.from_dict(spec)   # -> Button(...)

Child dictionary compatibility in 0.7.0

Every child mapping in Container.children, Card.content, Grids.children, Collapsible.content, TabItem.content and ActionGroup.buttons must carry a registered, non-empty string type. Missing, empty, malformed and unknown discriminators raise a Pydantic ValidationError instead of producing an empty generic primitive. This narrows accepted input and therefore bumps the minor version.

Tagged children retain their concrete type and fields across lists, tuples, deques and generators, including mapping wrappers. Existing primitive instances retain their identity. Valid serialized wire dictionaries, aliases, defaults and trusted attributes precedence remain unchanged; callers using untagged child dictionaries must add the intended concrete type before upgrading.

Fluent addition compatibility in 0.10.0

Container.add(), Card.add() and Grids.add() (also Grid.add()) validate every supplied child before changing the existing collection. They accept primitive instances and tagged mappings, including mapping wrappers, using the same concrete-type validation as constructors. Invalid scalars, untagged mappings, malformed or unknown types, and invalid nested children raise ValueError without partially adding valid siblings or replacing the existing collection.

Valid additions remain chainable, preserve existing primitive identities and retain serialized defaults, aliases and trusted attributes precedence. Calling add() without arguments leaves the tree unchanged. Rejecting previously accepted invalid children narrows the Python API and therefore bumps the minor version; callers must supply valid primitive instances or tagged mappings when upgrading.

Finite-number compatibility in 0.9.0

Wire serialization rejects NaN, positive infinity and negative infinity with a field path. The check applies to to_dict(), model_dump(), to_json() and from_dict(), including nested charts, payloads, trusted attributes, numeric mapping keys, models and dataclasses after their serializers expand them. to_json(allow_nan=True) raises ValueError; callers cannot opt into non-standard numeric tokens.

This narrows accepted input and therefore bumps the minor version. Finite numbers, serialized defaults, aliases, omission rules and trusted attributes precedence retain their existing wire representation. Consumers adopting this package must replace non-finite values with an application-defined finite or nullable value before crossing the wire boundary. This policy does not introduce conversions for other Python objects that standard JSON cannot encode.

Shipping a response

from astralprims import create_ui_response, Text, Button

create_ui_response([Text(content="hi"), Button(label="ok", action="go")])
# {"_ui_components": [{...}, {...}], "_data": None}

A FastAPI endpoint can return primitive.to_dict() or create_ui_response(...) directly.

Wire schema

Version 0.8.0 adds the public wire_schema() Python API. It describes the existing serialized vocabulary; it does not add a primitive or change wire output. Consumers adopt the new package release through their own version pins.

wire_schema() returns a JSON Schema (draft 2020-12) describing the canonical to_dict() output of every built-in primitive in this release. Each wire type gets an entry under $defs, and the root schema validates any serialized component directly:

from astralprims import wire_schema

schema = wire_schema()
schema["x-astralprims-version"]  # matches astralprims.__version__
schema["$defs"]["button"]        # the wire shape of Button output

Fields set to None are omitted from wire output entirely, as is an empty css block, so those properties do not accept an explicit top-level null in the schema; nested values permitted by a field's own type, such as entries in unconstrained lists or dictionaries, may still contain null. Non-None defaults of declared wire properties are always emitted and appear as the schema default of their property. attributes is a trusted escape hatch merged last into the top-level object: extra keys are always accepted, but overriding a declared key, including type, may intentionally produce output outside this baseline schema. Validating output against this schema is not sanitization, and never a security boundary, because attributes can carry any trusted keys — only trusted data may enter it. Custom registered primitives stay supported through Primitive.from_dict() but are not part of this versioned baseline.

Built-in primitives

Group Primitives
Layout Container, Card, Grid/Grids, Tabs (+ TabItem), Collapsible, Divider
Content Text, Button, ActionGroup, Input, ParamPicker, Image, CodeBlock, Alert, ProgressBar, MetricCard, List_, Table
Charts BarChart, LineChart, PieChart, DonutChart, RadarChart, PlotlyChart (+ ChartDataset)
Media/IO Audio, FileUpload, FileDownload
Dashboard Badge, Hero, KeyValue, Timeline, Rating, StatGroup, Gauge, PipelineStepper, ChatHistory
Theming ColorPicker, ThemeApply

Composite readouts

Six types describe a whole readout rather than a single value, so a server can send the shape it means instead of assembling it from loose parts.

Primitive Wire type What it carries
ActionGroup action_group buttons (nested Button primitives), align (start/center/end/between), label for the group's accessible name
StatGroup stat_group title, items of {label, value, delta?, trend?, hint?, variant?}, columns (clamped 1-6)
Gauge gauge label, value on the same 0-1 scale as ProgressBar, display_value, ascending thresholds of {at, variant}, subtitle
PipelineStepper pipeline_stepper title, steps of {label, status, detail?} where status is done/active/pending/error, orientation
DonutChart donut_chart title, parallel labels/data, and center_label/center_value for the hole
RadarChart radar_chart title, axes, datasets of {label, data} parallel to the axes, optional max_value
from astralprims import ActionGroup, Button, Gauge, StatGroup

Gauge(label="Humidity", value=0.62, display_value="62%",
      thresholds=[{"at": 0.0, "variant": "success"},
                  {"at": 0.8, "variant": "warning"}])

StatGroup(title="This week", columns=3, items=[
    {"label": "Requests", "value": "1,284", "delta": "+12%", "trend": "up"},
    {"label": "Errors", "value": "3", "trend": "down", "variant": "success"},
    {"label": "p95", "value": "412 ms"},
])

ActionGroup(label="Result actions", align="end", buttons=[
    Button(label="Save", action="save_result"),
    Button(label="Export", action="export_result", variant="secondary"),
])

None of these carry a color. Variant strings name a semantic role and the renderer resolves it from the active theme.

Primitive reference

Every primitive serializes to a dict with its wire type first, then the common fields, then its own fields. Fields left at None and an empty css are dropped; every other default is emitted and is part of the wire contract. The wire type does not always match the class name: CodeBlock is code, ProgressBar is progress, MetricCard is metric, List_ is list, KeyValue is keyvalue, and Grid is an alias of Grids, which is grid. In a type, Primitive means any nested primitive; a nested dict is rebuilt as the class registered for its type.

Common fields

Field Type Default
css Optional[Dict[str, str]] None
id Optional[str] None
class_name (wire class) Optional[str] None
tooltip Optional[str] None
attributes Dict[str, Any] {}

Primitives

Class Wire type Field Type Default
Container container children List[Primitive] []
direction Optional[str] None
Card card title str ""
content List[Primitive] []
variant str "default"
Grids (alias Grid) grid columns int 2
children List[Primitive] []
gap int 20
Tabs tabs tabs List[TabItem] []
variant str "default"
Collapsible collapsible title str ""
content List[Primitive] []
default_open bool False
Divider divider variant str "solid"
Text text content str ""
variant str "body"
Button button label str ""
action str ""
payload Dict[str, Any] {}
variant str "primary"
ActionGroup action_group buttons List[Primitive] []
align str "start"
label Optional[str] None
Input input placeholder str ""
name str ""
value str ""
ParamPicker param_picker title str ""
description str ""
fields List[Dict[str, Any]] []
submit_label str "Submit"
submit_message_template str ""
Image image url str ""
alt Optional[str] None
width Optional[str] None
height Optional[str] None
CodeBlock code code str ""
language str "text"
show_line_numbers bool False
Alert alert message str ""
variant str "info"
title Optional[str] None
ProgressBar progress value float 0.0
label Optional[str] None
variant str "default"
show_percentage bool True
MetricCard metric title str ""
value str ""
subtitle Optional[str] None
icon Optional[str] None
variant str "default"
progress Optional[float] None
List_ list items List[Union[str, Dict[str, Any]]] []
ordered bool False
variant str "default"
Table table headers List[str] []
rows List[List[Any]] []
variant str "default"
total_rows Optional[int] None
page_size Optional[int] None
page_offset Optional[int] None
page_sizes List[int] []
source_tool Optional[str] None
source_agent Optional[str] None
source_params Dict[str, Any] {}
BarChart bar_chart title str ""
labels List[str] []
datasets List[Dict[str, Any]] []
LineChart line_chart title str ""
labels List[str] []
datasets List[Dict[str, Any]] []
PieChart pie_chart title str ""
labels List[str] []
data List[float] []
colors List[str] []
DonutChart donut_chart title str ""
labels List[str] []
data List[float] []
center_label Optional[str] None
center_value Optional[str] None
RadarChart radar_chart title str ""
axes List[str] []
datasets List[Dict[str, Any]] []
max_value Optional[float] None
PlotlyChart plotly_chart title str ""
data List[Dict[str, Any]] []
layout Dict[str, Any] {}
config Dict[str, Any] {}
Audio audio src str ""
contentType Optional[str] None
autoplay bool False
loop bool False
label Optional[str] None
showControls bool True
description Optional[str] None
FileUpload file_upload label str "Upload File"
accept str "*/*"
action str ""
FileDownload file_download label str "Download File"
url str ""
filename Optional[str] None
Badge badge label str ""
variant str "default"
icon Optional[str] None
Hero hero title str ""
subtitle Optional[str] None
eyebrow Optional[str] None
icon Optional[str] None
variant str "default"
badges List[str] []
KeyValue keyvalue title Optional[str] None
items List[Dict[str, Any]] []
columns int 2
Timeline timeline title Optional[str] None
items List[Dict[str, Any]] []
variant str "default"
StatGroup stat_group title Optional[str] None
items List[Dict[str, Any]] []
columns int 4
Gauge gauge label str ""
value float 0.0
display_value Optional[str] None
thresholds List[Dict[str, Any]] []
subtitle Optional[str] None
PipelineStepper pipeline_stepper title Optional[str] None
steps List[Dict[str, Any]] []
orientation str "horizontal"
Rating rating value float 0.0
max_value int 5
label Optional[str] None
subtitle Optional[str] None
show_value bool True
ChatHistory chat_history title Optional[str] "Recent chats"
items List[Dict[str, Any]] []
ColorPicker color_picker label str ""
color_key str ""
value str "#000000"
ThemeApply theme_apply preset Optional[str] None
colors Optional[Dict[str, str]] None
color_key Optional[str] None
color_value Optional[str] None
message str ""

Nested models

Class Field Type Default
TabItem label str ""
content List[Primitive] []
value Optional[str] None
ChartDataset label str ""
data List[float] []
color Optional[str] None

attributes is never emitted as a key: its entries are merged into the top level last and can overwrite any field, including type, so it must carry only trusted keys, never user or model input.

This reference is generated from the models. After changing a primitive, run uv run --frozen python tooling/python-ci/primitive_reference.py to regenerate it; tests/test_readme_reference.py fails while it is stale.

Defining your own primitive

Primitives are pydantic models; declare the wire type as a Literal default and subclassing auto-registers it for from_dict — no manual map. Pick a type string that does not collide with a built-in:

from typing import Literal, Optional
from astralprims import Primitive, rebuild_primitive_union

class Ribbon(Primitive):
    type: Literal["ribbon"] = "ribbon"   # registered automatically
    label: str = ""
    count: Optional[int] = None

rebuild_primitive_union()  # refresh AnyPrimitive/primitive_adapter

Collision errors and validation

Every primitive subclass must declare a type field with a non-empty string default that matches any Literal annotation choices. Wire types must be globally unique across built-in and custom primitives. Attempting to register a wire type that is already bound to another class raises PrimitiveTypeCollisionError before modifying the registry. Failed registrations abort cleanly without altering _REGISTRY or existing primitive_adapter instances.

Unregistering and rebuild ordering

To remove an extension primitive or prepare for a replacement, unregister the wire type name using unregister_primitive() and then call rebuild_primitive_union():

from astralprims import unregister_primitive, rebuild_primitive_union

unregister_primitive("ribbon")
rebuild_primitive_union()

Reload policy

Re-registering the exact same class object (for example, in interactive environments) is permitted. However, module reloads create a new, distinct class object for the same wire type, which triggers PrimitiveTypeCollisionError. To reload a module defining custom primitives, call unregister_primitive() on the custom wire types before reloading the module, then call rebuild_primitive_union().

Tests

pytest

License

Apache-2.0

About

UI primitive definitions for the Astral ecosystem

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages