Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
python-version: "3.12"

- name: Build sdist and wheel
run: uv build

# Catches a malformed README or broken metadata before anything is
# uploaded — a bad upload cannot be replaced, only yanked.
- name: Validate distributions
run: uvx twine check dist/*

# The tag is the release's identity; if it disagrees with the version in
# pyproject.toml the wheel would be published under a name the tag does
# not describe. Fail here rather than after upload.
- name: Tag must match the declared version
if: startsWith(github.ref, 'refs/tags/v')
run: |
declared=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
tagged="${GITHUB_REF_NAME#v}"
echo "pyproject: $declared / tag: $tagged"
test "$declared" = "$tagged"

# The package is not only Python. The client JS, its vendored Idiomorph
# copy, the stylesheet, and the PEP 561 marker all live inside the
# package so hatchling picks them up with no explicit include — which is
# exactly what would make a regression here quiet. A wheel missing
# component-client.js installs and imports cleanly and then serves no
# interactivity; a wheel missing py.typed types as `Any` everywhere with
# no error at all.
- name: Wheel must contain the client assets and the typing marker
run: |
python - <<'PY'
import pathlib, sys, zipfile

wheel = next(iter(sorted(pathlib.Path("dist").glob("*.whl"))))
names = zipfile.ZipFile(wheel).namelist()
print(f"{wheel.name}: {len(names)} entries")

missing = [
required
for required in [
"component_framework/py.typed",
"component_framework/static/component_framework/js/component-client.js",
"component_framework/static/component_framework/js/component-client.d.ts",
"component_framework/static/component_framework/js/vendor/idiomorph.js",
"component_framework/static/component_framework/css/component-framework.css",
]
if not any(n.endswith(required) for n in names)
]
if missing:
sys.exit("wheel is missing shipped content:\n " + "\n ".join(missing))

print("ok: client assets and py.typed present")
PY

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
# Gates the upload behind a named environment, so protection rules can
# require a review before anything reaches PyPI.
environment:
name: pypi
url: https://pypi.org/p/component-framework
permissions:
# Required for trusted publishing: the job mints a short-lived OIDC
# token that PyPI exchanges for an upload token. No API token is stored.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] - 2026-07-30

First release published to PyPI. No behavioural change from `0.6.0b0` — the
beta is promoted to a final release so that dependents can require a stable
version. `cf-ui` declares `component-framework>=0.4`, and a specifier without
a pre-release marker only resolves to a pre-release when no final release
exists; relying on that fallback would mean the resolution changed silently
the first time any stable version appeared.

The rest of this entry is what an audit of the built wheel turned up: the
package was installable, but not yet fit to *hand to someone*.

### Added

- Trusted publishing to PyPI via GitHub Actions OIDC on a `v*` tag (#47). No
API token is stored in the repository. The build job also refuses to hand
off a wheel missing the client assets or `py.typed` — those ship inside the
package with no explicit include, so a packaging regression would produce a
wheel that installs and imports cleanly and then serves no interactivity.
- **`py.typed` (#49).** The codebase is type-checked in CI and ships
`component-client.d.ts` for the JS, but without the PEP 561 marker every
Python consumer running mypy or pyright saw the whole package as untyped.
The types existed; they were not advertised.
- **A `testing` extra (#49)** declaring pytest.
`component_framework.testing` imports pytest at module scope — it ships
fixtures and a pytest-style base class — but pytest was only reachable via
`dev-base`, so following the README's testing sample after
`pip install component-framework[fastapi]` raised `ModuleNotFoundError`.
- **Classifiers** for the license (which is what PyPI's sidebar reads), the
frameworks the adapters target, and `Typing :: Typed`.

### Fixed — documentation that did not survive contact with the package (#49)

Found by building the wheel, installing it into a clean venv, and checking
every documented import against the *installed* package rather than the
source tree.

- **The README described an install nobody could perform.** Every instruction
was `pip install -e ".[extra]"` — an editable install from a checkout — and
the section opened with "Not on PyPI yet". The README *is* the PyPI landing
page, so the one line a visitor arriving there needed was the one that was
missing. It now leads with `pip install "component-framework[fastapi]"`,
documents the quoting (bare brackets are glob syntax in zsh), shows the
missing-extra `ImportError` a reader will actually meet, and demotes the
editable install to a contributor note.
- **The README's composition example was invented.** It imported
`SlotComponent` and `CompositeComponent` from `core.composition`, which
exports neither, and set a `components = {...}` attribute nothing reads.
The real API is a `Component` with a `slots` ClassVar, assembled with
`compose()`.
- **The README's testing example used methods that do not exist**
(`mount_component`, `dispatch_event`, and `assert_state` with a positional
component argument), and omitted the required `component_class`.
- **`docs/LOCKED_FIELDS.md`** imported `Component` and `registry` from the
top-level package, which exports only `CorruptStateError` and
`StateSigner`.
- **`docs/CBV_GUIDE.md`** imported `RateLimitMixin` from
`adapters.django_views`; it lives in `adapters.django_ratelimit`, as the
README said all along.
- **Two `docs/examples/ecommerce.md` samples did not parse** — a bare `...`
inside a list literal, and a method whose body was only a comment.

### Added — a test that reads the docs (#49)

`tests/test_docs_samples.py` parses every fenced `python` block in the
README, CONTRIBUTING, and `docs/`, and resolves every
`from component_framework… import …` against the real package. Nothing here
read the documentation before, which is why all six defects above shipped;
the guard goes red on every one of them when run against the previous text.
It also fails if the README ever again claims the package is not on PyPI.
Ported from cf-ui's `test_docs_samples.py`, which exists because
`ComponentCatalog` and `<CfCard>` sat in *that* README for two releases.

## [0.6.0b0] - 2026-07-20

### Added
Expand Down
85 changes: 59 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Component Framework

> **Beta** — the core lifecycle, permissions, composition, and testing utilities are stable, but the public API can still change before 1.0. Not yet published to PyPI (see [Installation](#installation)).
> **Beta** — the core lifecycle, permissions, composition, and testing utilities are stable, but the public API can still change before 1.0. See [Installation](#installation).

Server-driven UI components for Python web frameworks, in the style of Phoenix LiveView and Laravel Livewire: state and event handling live on the server, and [HTMX](https://htmx.org/) handles the client-side wiring instead of a JavaScript framework.

Expand Down Expand Up @@ -64,28 +64,45 @@ A few things are Django-only today even though the underlying hook is framework-

## Installation

**Not on PyPI yet** — install from source:

```bash
git clone https://github.com/fsecada01/component-framework.git
cd component-framework

# uv (recommended)
uv pip install -e ".[fastapi]" # or [django] / [litestar] / [flask] / [all]
uv add "component-framework[fastapi]" # or [django] / [litestar] / [flask]

# or with pip
pip install -e ".[fastapi]"
pip install "component-framework[fastapi]"
```

`pydantic>=2.0` is the only mandatory dependency; everything else — FastAPI, Django, Litestar, Flask, JinjaX, Channels — is an optional extra so you only pull in what you use.
Pick the extra for the web framework you're on. `pydantic>=2.0` is the only mandatory dependency; everything else — FastAPI, Django, Litestar, Flask, JinjaX, Channels — is optional, so you only pull in what you use.

```bash
pip install -e ".[fastapi]" # single adapter
pip install -e ".[fastapi,django,litestar,flask]" # several
pip install -e ".[all]" # everything, including dev-adjacent websockets extra
pip install "component-framework[fastapi]" # single adapter
pip install "component-framework[fastapi,django,litestar,flask]" # several
pip install "component-framework[all]" # every adapter, plus the websockets extra
pip install "component-framework[fastapi,testing]" # + the pytest helpers, see Testing below
```

> Extras were made optional in 0.3.0 — if you're on an older checkout that assumed `fastapi`/`uvicorn`/`jinjax` installed by default, see [CHANGELOG.md](https://github.com/fsecada01/component-framework/blob/master/CHANGELOG.md).
The quotes matter in most shells — bare brackets are glob syntax in zsh and get eaten before pip sees them.

Import an adapter whose extra you skipped and you get a deliberate error naming the fix, not a bare `ModuleNotFoundError`:

```
ImportError: 'jinjax' is not installed. Install the 'fastapi' extra:
pip install 'component-framework[fastapi]'
```

> Extras were made optional in 0.3.0 — if you're upgrading from before that and assumed `fastapi`/`uvicorn`/`jinjax` came by default, see [CHANGELOG.md](https://github.com/fsecada01/component-framework/blob/master/CHANGELOG.md).

### From a checkout

For hacking on the framework itself:

```bash
git clone https://github.com/fsecada01/component-framework.git
cd component-framework
uv pip install -e ".[dev]"
```

See [CONTRIBUTING.md](https://github.com/fsecada01/component-framework/blob/master/CONTRIBUTING.md).

---

Expand Down Expand Up @@ -204,17 +221,27 @@ class ContactForm(FormComponent):
```

```python
# Composition: slots + a composite parent
from component_framework.core.composition import SlotComponent, CompositeComponent
# Composition: a parent declares named slots, children fill them
from component_framework.core import Component, registry
from component_framework.core.composition import compose

@registry.register("card")
class Card(SlotComponent):
class Card(Component):
template_name = "card.html"
slots = ["header", "body", "footer"]

@registry.register("product_page")
class ProductPage(CompositeComponent):
components = {"card": Card, "cart": CartComponent}
slots = ["header", "body", "footer"] # omit to accept any slot name

@registry.register("cart_summary")
class CartSummary(Component):
template_name = "cart_summary.html"

# Assemble in one call. Each child's rendered HTML lands in the parent's
# template context under `slots`, keyed by slot name.
page = compose(
Card,
params={"title": "Your order"},
body=CartSummary(),
)
result = page.dispatch()
```

```python
Expand All @@ -233,15 +260,21 @@ class OrderEditor(DjangoModelComponent):
```

```python
# Testing a component without an HTTP server
# Testing a component without an HTTP server.
# Needs the `testing` extra: pip install "component-framework[testing]"
from component_framework.testing import ComponentTestCase

class TestCounter(ComponentTestCase):
component_class = Counter # a MockRenderer is installed per test

def test_initial_state(self):
result = self.mount()
assert result["state"]["count"] == 0

def test_increment(self):
component = self.mount_component("counter")
self.assert_state(component, count=0)
self.dispatch_event(component, "increment", amount=5)
self.assert_state(component, count=5)
self.mount()
self.dispatch("increment", {"amount": 5})
self.assert_state(count=5)
```

More worked examples: [`docs/examples/ecommerce.md`](https://github.com/fsecada01/component-framework/blob/master/docs/examples/ecommerce.md) (real-time cart), [`docs/examples/wizard.md`](https://github.com/fsecada01/component-framework/blob/master/docs/examples/wizard.md) (multi-step FastAPI wizard), and the runnable apps under [`examples/`](https://github.com/fsecada01/component-framework/tree/master/examples/).
Expand Down
3 changes: 2 additions & 1 deletion docs/CBV_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ class CachedView(CacheMixin, ComponentView):
Add rate limiting (requires django-ratelimit).

```python
from component_framework.adapters.django_views import RateLimitMixin, ComponentView
from component_framework.adapters.django_ratelimit import RateLimitMixin
from component_framework.adapters.django_views import ComponentView

class RateLimitedView(RateLimitMixin, ComponentView):
rate_limit_key = "component"
Expand Down
2 changes: 1 addition & 1 deletion docs/LOCKED_FIELDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ client must never influence, in either mode.
```python
from typing import ClassVar

from component_framework import Component, registry
from component_framework.core import Component, registry


@registry.register("account_panel")
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/ecommerce.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pip install component-framework django django-channels
```python
# settings.py
INSTALLED_APPS = [
...
...,
"channels",
"component_framework",
]
Expand Down Expand Up @@ -611,7 +611,7 @@ serialisation/deserialisation.
class CartComponent(Component):

def on_add_item(self, product_id: int, size: str):
# ... 10 lines of plain Python (see above) ...
... # 10 lines of plain Python, see above

def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
if event == "add_item":
Expand Down
19 changes: 18 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "component-framework"
version = "0.6.0b0"
version = "0.6.0"
description = "Framework-agnostic server components with LiveView-style interactivity"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -12,11 +12,21 @@ keywords = ["components", "server-components", "liveview", "htmx", "fastapi", "d
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
# PyPI's sidebar reads the license from this classifier, not from the
# `license` field.
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Framework :: Django",
"Framework :: FastAPI",
"Framework :: Flask",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Typing :: Typed",
]

dependencies = [
Expand Down Expand Up @@ -47,6 +57,13 @@ flask = [
websockets = [
"websockets>=12.0",
]
# `component_framework.testing` imports pytest at module scope — it ships
# pytest fixtures and a pytest-style base class. Without this extra a
# consumer following the README's testing sample hit ModuleNotFoundError,
# because pytest was only reachable through `dev-base` (#49).
testing = [
"pytest>=7.4.0",
]
dev-base = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
Expand Down
Empty file.
Loading
Loading