Metadata-Version: 2.4
Name: astralprims
Version: 0.11.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: jsonschema==4.25.1; 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.

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

### JSON-native values in 0.11.0

Arbitrary-valued wire slots (`payload`, `attributes`, `Table.rows`,
`Table.source_params`, chart `datasets`, and every other `Any`-typed field) now
serialize to plain JSON-native values, so `to_dict()` output is always
JSON-compatible and `json.loads(to_json())` equals `to_dict()`. Accepted
conversions: `datetime`, `date` and `time` become ISO 8601 strings, `UUID` and
`Decimal` become strings, `bytes` and `bytearray` must decode as UTF-8 and
become strings, and tuples, sets and frozensets become arrays in iteration
order. Non-string mapping keys are stringified with the standard JSON encoding
(`2` to `"2"`, `2.5` to `"2.5"`, `true` to `"true"`, `false` to `"false"`,
`null` to `"null"`); mapping keys of any other type are rejected.

Values that no conversion accepts —arbitrary objects, `complex`, `timedelta`,
plain `Enum` members, non-UTF-8 bytes, and unsupported mapping keys —raise
`ValueError` naming the type and field path at serialization time, the same
contract as the finite-number policy, instead of failing in a transport encoder.
The policy applies to `to_dict()`, `model_dump()`, `to_json()` and `from_dict()`,
including nested primitives, models and dataclasses after their serializers
expand them. `from_dict()` applies the same normalization to its input, and
callers needing the previous Python objects must convert them before
serialization or keep them outside the wire fields.

Trusted `attributes` keep their merge-last precedence over declared fields, and
accepted conversions apply to the merged values. JSON-native values, serialized
defaults, aliases and omission rules keep their existing wire representation.
Changing the emitted wire values for previously accepted Python objects is a
serialization-contract change and therefore bumps the minor version.

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

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

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

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