Metadata-Version: 2.4
Name: astralprims
Version: 0.7.0
Summary: Composable, serializable UI primitives for Python
Project-URL: Homepage, https://github.com/AstralDeep/AstralPrimitives
Project-URL: Issues, https://github.com/AstralDeep/AstralPrimitives/issues
Author-email: Sam Armstrong <admin@example.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: components,json,primitives,serialization,ui
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pydantic>=2
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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

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

## Quick start

```python
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

```python
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:

```python
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.

### Shipping a response

```python
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.

## 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` |

```python
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`.

<!-- primitive-reference:start -->

### 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` |

<!-- primitive-reference:end -->

`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:

```python
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()`:

```python
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

```bash
pytest
```

## License

Apache-2.0
