stario-create 5.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stario_create-5.0.0/.github/workflows/publish.yml +23 -0
- stario_create-5.0.0/.gitignore +5 -0
- stario_create-5.0.0/AGENTS.md +571 -0
- stario_create-5.0.0/PKG-INFO +86 -0
- stario_create-5.0.0/README.md +66 -0
- stario_create-5.0.0/llms.txt +42 -0
- stario_create-5.0.0/pyproject.toml +79 -0
- stario_create-5.0.0/setup.sh +20 -0
- stario_create-5.0.0/skills/stario-auth/SKILL.md +366 -0
- stario_create-5.0.0/skills/stario-feature/SKILL.md +73 -0
- stario_create-5.0.0/skills/stario-litewriter/SKILL.md +142 -0
- stario_create-5.0.0/skills/stario-tailwind-daisyui/SKILL.md +115 -0
- stario_create-5.0.0/src/stario_create/__init__.py +8 -0
- stario_create-5.0.0/src/stario_create/cli.py +387 -0
- stario_create-5.0.0/src/stario_create/data.py +43 -0
- stario_create-5.0.0/src/stario_create/py.typed +0 -0
- stario_create-5.0.0/src/stario_create/scaffold.py +228 -0
- stario_create-5.0.0/src/stario_create/tasks.py +102 -0
- stario_create-5.0.0/src/stario_create/templates/app/assets.py.tmpl +7 -0
- stario_create-5.0.0/src/stario_create/templates/app/common/shell.py.tmpl +20 -0
- stario_create-5.0.0/src/stario_create/templates/app/config.py.tmpl +15 -0
- stario_create-5.0.0/src/stario_create/templates/app/features/home/handlers.py.tmpl +15 -0
- stario_create-5.0.0/src/stario_create/templates/app/features/home/urls.py.tmpl +3 -0
- stario_create-5.0.0/src/stario_create/templates/app/features/home/views.py.tmpl +15 -0
- stario_create-5.0.0/src/stario_create/templates/app/main.py.tmpl +18 -0
- stario_create-5.0.0/src/stario_create/templates/app/style.css.tmpl +1 -0
- stario_create-5.0.0/src/stario_create/templates/pyproject_extras.toml +21 -0
- stario_create-5.0.0/src/stario_create/templates/screen/handlers.py.tmpl +15 -0
- stario_create-5.0.0/src/stario_create/templates/screen/test.py.tmpl +7 -0
- stario_create-5.0.0/src/stario_create/templates/screen/urls.py.tmpl +3 -0
- stario_create-5.0.0/src/stario_create/templates/screen/views.py.tmpl +11 -0
- stario_create-5.0.0/src/stario_create/templates/screen-live/data.py.tmpl +25 -0
- stario_create-5.0.0/src/stario_create/templates/screen-live/handlers.py.tmpl +46 -0
- stario_create-5.0.0/src/stario_create/templates/screen-live/test.py.tmpl +21 -0
- stario_create-5.0.0/src/stario_create/templates/screen-live/urls.py.tmpl +5 -0
- stario_create-5.0.0/src/stario_create/templates/screen-live/views.py.tmpl +24 -0
- stario_create-5.0.0/src/stario_create/templates/tests/conftest.py.tmpl +11 -0
- stario_create-5.0.0/src/stario_create/templates/tests/test_home.py.tmpl +7 -0
- stario_create-5.0.0/src/stario_create/vendor/datastar.js +9 -0
- stario_create-5.0.0/tests/test_scaffold.py +154 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
pypi:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
with:
|
|
19
|
+
enable-cache: true
|
|
20
|
+
|
|
21
|
+
- run: uv build
|
|
22
|
+
|
|
23
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
# Stario app guide
|
|
2
|
+
|
|
3
|
+
This file teaches a person or an agent how to build a Stario app. It sits at the root of every Stario app repo. Read it before you write code. It is the whole default. When the developer asks for something else, do that instead. App-specific notes live in the last section, `## This app`.
|
|
4
|
+
|
|
5
|
+
- Docs: https://stario.dev/docs
|
|
6
|
+
- Deeper context: https://stario.dev/llms-full.txt
|
|
7
|
+
- Skills (SQLite, auth, Tailwind, adding a screen): `.agents/skills/` in this repo, or `npx skills add bobowski/stario-create`
|
|
8
|
+
- Reference app: https://github.com/bobowski/stario/tree/main/examples/chat-room
|
|
9
|
+
- This file, the skills, and the project CLI: https://github.com/bobowski/stario-create
|
|
10
|
+
|
|
11
|
+
Stario is Python hypermedia. A handler writes HTML. Datastar morphs the page in the browser. A long-lived SSE stream keeps a page live. State lives on the server. Each screen is a full document, and a plain link moves between screens.
|
|
12
|
+
|
|
13
|
+
## Start a project
|
|
14
|
+
|
|
15
|
+
One command creates a runnable app with one static screen, this file, the skills, and a `justfile` of dev tasks:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx stario-create new my-app
|
|
19
|
+
cd my-app
|
|
20
|
+
just dev # http://127.0.0.1:8000, reloads on change (or: uv run watchfiles 'uv run python -m app.main' app/ static/)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`stario-create` runs `uv init --app --python 3.14`, removes the root `main.py`, adds `stario` plus the dev tools, writes the `pyproject.toml` blocks below, creates the tree in the next section with a `home` screen, vendors `static/js/datastar.js`, writes `AGENTS.md` and a `CLAUDE.md` that imports it, ignores `*.sqlite3*`, installs the four skills into `.agents/skills/`, writes the `justfile`, and runs the tests once to prove the app works.
|
|
24
|
+
|
|
25
|
+
Without the CLI, do the same by hand. The `pyproject.toml` needs these blocks:
|
|
26
|
+
|
|
27
|
+
```toml
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
target-version = "py314"
|
|
30
|
+
line-length = 100
|
|
31
|
+
src = ["app", "tests"]
|
|
32
|
+
extend-exclude = [".agents", ".claude", ".cursor"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.isort]
|
|
38
|
+
known-first-party = ["app"]
|
|
39
|
+
|
|
40
|
+
[tool.pyright]
|
|
41
|
+
pythonVersion = "3.14"
|
|
42
|
+
include = ["app", "tests"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
pythonpath = ["."]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Check before you call work done — `just check`, or `stario-create check`, or the four steps:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run ruff check .
|
|
54
|
+
uv run ruff format --check .
|
|
55
|
+
uv run pyright
|
|
56
|
+
uv run pytest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Stario does not read `.env`. Configure with environment variables; `serve` reads `STARIO_*` through `ServerConfig.from_env()`. `STARIO_PORT` changes the port.
|
|
60
|
+
|
|
61
|
+
## Where does it go
|
|
62
|
+
|
|
63
|
+
| You need to add | It goes in |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| A URL | `Route` value in `app/features/<screen>/urls.py` |
|
|
66
|
+
| A table or a query | `SCHEMA` and a function in `app/features/<screen>/data.py` |
|
|
67
|
+
| HTML | the tree inside `render` in `app/features/<screen>/views.py` |
|
|
68
|
+
| Input validation | the command handler in `app/features/<screen>/handlers.py` |
|
|
69
|
+
| A new screen | a new folder `app/features/<screen>/`, then `register` in `app/main.py` (skill `stario-feature`) |
|
|
70
|
+
| The `<html>` document | `page(title, body)` in `app/common/shell.py` |
|
|
71
|
+
| Who the user is | a signed cookie read by middleware into `c.state["user"]` (skill `stario-auth`) |
|
|
72
|
+
| Which tab sent the action | a signal minted per tab — a visitor id, not a user |
|
|
73
|
+
| A setting or a secret | a field on `Config` in `app/config.py`, read from the environment |
|
|
74
|
+
| A stylesheet or an image | `static/`, served through `ASSETS` in `app/assets.py` (CSS at scale: skill `stario-tailwind-daisyui`) |
|
|
75
|
+
| A small UI behaviour | a Datastar expression on the element: `data.on("click", "el.closest('dialog').showModal()")` |
|
|
76
|
+
| A test | `tests/test_<screen>.py` |
|
|
77
|
+
| Something another screen needs | a function in that screen's `data.py`, imported by the other screen |
|
|
78
|
+
|
|
79
|
+
## Layout: one folder per screen
|
|
80
|
+
|
|
81
|
+
Every screen of the app is one feature folder. A screen is a page or a resource with its own URL: `/`, `/settings`, `/rooms/{room_id}`. The folder owns the URLs, the data, the views, and the handlers of that screen. The number of folders tracks the number of screens. When a screen goes away, delete its folder.
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
app/
|
|
85
|
+
main.py bootstrap: build dependencies, register every feature
|
|
86
|
+
config.py frozen Config dataclass, from_env(), read once in bootstrap
|
|
87
|
+
assets.py ASSETS = Assets(...), STYLE_CSS, DATASTAR_JS
|
|
88
|
+
common/
|
|
89
|
+
shell.py page(title, body): the <html> document, @baked
|
|
90
|
+
features/
|
|
91
|
+
<screen>/
|
|
92
|
+
urls.py Route values for this screen
|
|
93
|
+
data.py SCHEMA + query functions, when the screen owns tables
|
|
94
|
+
views.py render(state) -> full document
|
|
95
|
+
handlers.py handlers + register
|
|
96
|
+
static/
|
|
97
|
+
js/datastar.js vendored client
|
|
98
|
+
css/style.css stylesheet
|
|
99
|
+
tests/
|
|
100
|
+
conftest.py TestClient(bootstrap) fixture
|
|
101
|
+
test_<screen>.py one file per screen
|
|
102
|
+
justfile dev tasks: just dev, just check, just test
|
|
103
|
+
AGENTS.md this file
|
|
104
|
+
CLAUDE.md one line: @AGENTS.md
|
|
105
|
+
.agents/skills/ installed skills
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Rules for the tree:
|
|
109
|
+
|
|
110
|
+
- Use this layout from the first commit, also with one screen. There is no later migration.
|
|
111
|
+
- `urls.py`, `views.py`, and `handlers.py` always exist. `data.py` exists when the screen owns tables. Each folder has an `__init__.py`. No other files by default. Add `models.py` or `subjects.py` only when `data.py` or `handlers.py` has become hard to read.
|
|
112
|
+
- A feature is self-contained. It owns its tables, its HTML, and its handler logic.
|
|
113
|
+
- A feature may import another feature's `urls.py` for links and another feature's `data.py` for reads. Imports flow one way. Do not import views across features.
|
|
114
|
+
- `common/` holds behaviour every screen shares: the document shell, the session cookie, time formatting. It does not hold borrowed views or borrowed queries.
|
|
115
|
+
- Two screens with similar HTML keep their own copies. Merge later as an optimization, when the copies have proven identical. Do not merge by default.
|
|
116
|
+
- `app/db.py` exists only when more than one feature needs the same connection object. Keep it thin: connect, transaction, apply schema.
|
|
117
|
+
- No JavaScript files by default. Datastar expressions on elements cover small UI behaviour. A script file is a deliberate exception with a reason in `## This app`.
|
|
118
|
+
|
|
119
|
+
## Stario invariants
|
|
120
|
+
|
|
121
|
+
Stario is not ASGI. Do not mount it in Uvicorn, Hypercorn, FastAPI, or Starlette. Do not return response objects. Run the module: `python -m app.main`. `app/main.py` ends with `if __name__ == "__main__": asyncio.run(serve(bootstrap))`.
|
|
122
|
+
|
|
123
|
+
Default imports:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from stario import App, Context, Relay, Route, Span, Writer, h
|
|
127
|
+
from stario.datastar import SSE, at, data, read_signals
|
|
128
|
+
from stario.markup import HtmlElement, baked, classes
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Import order in every file: standard library, then third-party (`stario`, `pytest`), then `app`. The `I` rule in `ruff` enforces it.
|
|
132
|
+
|
|
133
|
+
Rules:
|
|
134
|
+
|
|
135
|
+
- `bootstrap` is `async def bootstrap(app: App, span: Span)` with exactly one `yield`. Build dependencies, attach assets, and register routes before `yield`. Clean up after `yield`.
|
|
136
|
+
- A handler is `async def handler(c: Context, w: Writer) -> None`. It completes the response through `w.html`, `w.empty`, `w.redirect`, or `SSE(w)`. It completes the response once.
|
|
137
|
+
- A `Route` is one method on one path: `Route("GET /rooms/{room_id}")` or `Route("POST", ROOM_PATH + "/send")`. Register with `app.add(route, handler)`. Build links with `route.href(room_id)` or `route.href(room_id=x)`. There is no `url_for`.
|
|
138
|
+
- `c.match.params["room_id"]` reads a path capture. `c.req.query` reads the query string. `c.req.cookies` reads cookies. `c.state` is a per-request dict that middleware fills.
|
|
139
|
+
- Middleware is `(inner: Handler) -> Handler`. `app.use("/prefix", middleware)` applies it to every route under the prefix, including the 404/405 answers. `app.use` and `app.add` work in any order; the router composes middleware when the app compiles.
|
|
140
|
+
- A long-lived loop iterates `c.alive(source)`. The loop ends on disconnect or shutdown. Cleanup after the loop always runs.
|
|
141
|
+
- Background work uses `c.app.create_task(...)`, not bare `asyncio.create_task`.
|
|
142
|
+
- Write the 4xx and `return` for a malformed request: `w.text("message", status=400)`. A missing row gets `status=404`. A user who may not do this gets `status=403`.
|
|
143
|
+
- An exception type that maps to one response everywhere is declared once and mapped with `catch_errors`: `app.use("/", catch_errors(MyError, respond=responder))`. `responder` is `async def responder(c, w, exc)`; import `catch_errors` from `stario.http.middleware`.
|
|
144
|
+
- Framework JSON goes through `stario.json`. Do not call `json.dumps` for signal or response data.
|
|
145
|
+
- Stario runs on one event loop in one process. Shared state is a plain object passed into the feature. No globals.
|
|
146
|
+
|
|
147
|
+
## The default architecture
|
|
148
|
+
|
|
149
|
+
Decide per screen:
|
|
150
|
+
|
|
151
|
+
- A screen with no action is a static page. One `GET` handler. `w.html(render(state))`. Done.
|
|
152
|
+
- A screen with any action is live. Three kinds of routes. This is CQRS over HTTP.
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
GET /board first document
|
|
156
|
+
GET /board/live SSE stream that stays open and repaints the page
|
|
157
|
+
POST /board/... command: validate, reply 204, change state, publish
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The stream is the only place that paints. A command never returns HTML. If a command returned HTML, the tab that clicked would race the stream, and other tabs would stay stale.
|
|
161
|
+
|
|
162
|
+
The loop:
|
|
163
|
+
|
|
164
|
+
1. `GET /board` renders `render(state)`.
|
|
165
|
+
2. The page opens `GET /board/live` on load with `retry="always"`. One stream per page.
|
|
166
|
+
3. The stream sends `render(state)` once, then waits for a notification.
|
|
167
|
+
4. A command validates the input, replies `w.empty()`, changes the store, publishes a notification.
|
|
168
|
+
5. Every open stream wakes, reads state again, sends `render(state)`. Datastar morphs the page.
|
|
169
|
+
|
|
170
|
+
The `204` means "valid request accepted" — validation always precedes it. Doing the work inline before the reply is fine when it is light; the reply does not carry the UI either way, the state change does.
|
|
171
|
+
|
|
172
|
+
Every command is a Datastar action: `data.on("click", at.post(ROUTE.href()))` or `data.on("submit", at.post(...), prevent=True)` on a form. Stario has no form-body parser, so a plain HTML `<form method="post">` is not used.
|
|
173
|
+
|
|
174
|
+
Notifications are `Relay` subjects: dotted strings such as `"board.changed"` or `f"room.{room_id}.message"`. `relay.publish(subject, payload)` wakes every `relay.subscribe(pattern)` whose pattern matches. `"room.*"` matches every event of every room. Define each feature's subjects as constants or small functions at the top of its `handlers.py`. A store with a watch feature can replace `Relay` for row changes (skill `stario-litewriter`). For more than one process, swap `Relay` for a broker and keep the same three routes.
|
|
175
|
+
|
|
176
|
+
Three kinds of bad input, three replies:
|
|
177
|
+
|
|
178
|
+
- Honest but empty or unchanged input (blank title, same color): reply `204` and do nothing.
|
|
179
|
+
- A domain conflict (name taken, room full): write the outcome to state, publish, and let `render` show the message. Failure is a view of state.
|
|
180
|
+
- Malformed or forged input (bad color format, unknown id, another user's row): write the 4xx and `return` — `w.text("why", status=400)`. A real client never sends it. Many handlers with one error shape declare an exception type and map it once with `catch_errors`.
|
|
181
|
+
|
|
182
|
+
A command that moves the user to another screen does not reply 204. It replies `SSE(w).navigate(ROOM.href(room.id))`. Navigation between screens by the user is a plain `<a href=...>`.
|
|
183
|
+
|
|
184
|
+
Identity has three kinds. Resource identity (`room_id`, `tile_id`) lives in the URL. A signed-in user lives in a signed cookie, verified by middleware into `c.state["user"]` (skill `stario-auth`). A lighter third kind is the visitor id: minted per tab, kept in `sessionStorage`, sent as a signal. It means "this open tab" — attribution for actions and for own-message highlighting, never authentication. A handler does not treat it as a user. The reference chat-room uses it for its visitors.
|
|
185
|
+
|
|
186
|
+
## Render from state
|
|
187
|
+
|
|
188
|
+
Each live screen has one function, `render(state) -> HtmlElement`, that returns the full document. The first `GET` and every stream wake call the same function. `ui = f(state)` stays visible.
|
|
189
|
+
|
|
190
|
+
- The stream sends the whole document: `sse.patch_elements(render(state))`. Datastar morphs the live DOM. Compression on the open stream makes repeated HTML cheap. Do not start with small targeted patches. Narrow a patch only after you have measured a cost.
|
|
191
|
+
- `render` does not fetch. The handler loads state and passes it in. State is a frozen dataclass or the rows the queries return.
|
|
192
|
+
- When the page differs per viewer (own messages highlighted, own draft), `render(state, viewer)` takes the viewer too. Each stream renders for its own viewer. The viewer is `c.state["user"]` for a signed-in user, or the per-tab visitor id when the app has no accounts.
|
|
193
|
+
- The element that carries `data.init(...)` and `data.signals(...)` keeps the same tag and `id` in every render. Datastar morphs it in place, so the stream is not reopened. A changed tag or `id` would replace the element and reconnect.
|
|
194
|
+
- The document shell is `@baked`. A baked function compiles once. Inside a baked function, a parameter is a whole child or a whole attribute value. Loops, conditions, and string formatting happen in the caller.
|
|
195
|
+
- Write the HTML tree inline inside `render`. A helper view exists for a block that repeats in a loop. A helper view does not exist for one call site. Do not build a helper per element.
|
|
196
|
+
- Repetition inside one feature is merged inside that feature. Repetition across features is left alone until it has proven stable.
|
|
197
|
+
|
|
198
|
+
## Signals and UI state
|
|
199
|
+
|
|
200
|
+
Signals are form input. Nothing else by default.
|
|
201
|
+
|
|
202
|
+
- `data.bind("color")` binds an input to a signal. `await read_signals(c.req)` reads every signal on each `@get` or `@post`.
|
|
203
|
+
- One `data.signals({...}, if_missing=True)` block per page, on the element that opens the stream. `if_missing=True` keeps what the user typed when the page morphs again.
|
|
204
|
+
- The server never trusts a signal. Every command validates the signal and reads truth from the store.
|
|
205
|
+
- Resource identity lives in the URL. A signed-in user lives in the session cookie. A signal holds neither — the per-tab visitor id is the one exception, and it is never trusted as a user.
|
|
206
|
+
- The stream patches HTML. It does not patch signals. One exception: after a command consumes an input, the stream or the command may reset that input with `sse.patch_signals({"message": ""})`.
|
|
207
|
+
- UI state the user controls must survive a morph. A morph arrives at any moment. If it closes the dialog the user just opened, the app feels broken. Protect that state in the HTML:
|
|
208
|
+
- `data.preserve_attr("open")` on `<dialog>` and `<details>`. The morph keeps the current value.
|
|
209
|
+
- `data.ignore_morph()` on a subtree the client owns, such as a text editor with its own state.
|
|
210
|
+
- Do not render `{"open": True}` from the server for a dialog the user opens.
|
|
211
|
+
- Signal names are `snake_case` on the wire. Nested state is a nested object, not a dotted name.
|
|
212
|
+
|
|
213
|
+
## A complete live screen
|
|
214
|
+
|
|
215
|
+
The files in build order. Copy them top to bottom and the app runs after the last one.
|
|
216
|
+
|
|
217
|
+
`app/config.py`:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
import os
|
|
221
|
+
from dataclasses import dataclass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class Config:
|
|
226
|
+
db_path: str
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_env(cls) -> Config:
|
|
230
|
+
return cls(db_path=os.environ.get("APP_DB_PATH", "app.sqlite3"))
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`app/assets.py`:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from pathlib import Path
|
|
237
|
+
|
|
238
|
+
from stario import Assets
|
|
239
|
+
|
|
240
|
+
ASSETS = Assets(Path(__file__).resolve().parents[1] / "static", url_prefix="/static")
|
|
241
|
+
STYLE_CSS = ASSETS.href("css/style.css")
|
|
242
|
+
DATASTAR_JS = ASSETS.href("js/datastar.js")
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`app/common/shell.py`:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from stario import h
|
|
249
|
+
from stario.datastar import ModuleScript
|
|
250
|
+
from stario.markup import HtmlElement, baked
|
|
251
|
+
|
|
252
|
+
from app.assets import DATASTAR_JS, STYLE_CSS
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@baked
|
|
256
|
+
def page(title: str, body: HtmlElement) -> HtmlElement:
|
|
257
|
+
return h.HtmlDocument(
|
|
258
|
+
{"lang": "en"},
|
|
259
|
+
h.Head(
|
|
260
|
+
h.Meta({"charset": "utf-8"}),
|
|
261
|
+
h.Meta({"name": "viewport", "content": "width=device-width, initial-scale=1"}),
|
|
262
|
+
h.Title(title),
|
|
263
|
+
h.Link({"rel": "stylesheet", "href": STYLE_CSS}),
|
|
264
|
+
ModuleScript(DATASTAR_JS),
|
|
265
|
+
),
|
|
266
|
+
h.Body(body),
|
|
267
|
+
)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`app/features/board/urls.py`:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from stario import Route
|
|
274
|
+
|
|
275
|
+
BOARD = Route("GET /board")
|
|
276
|
+
BOARD_LIVE = Route("GET /board/live")
|
|
277
|
+
PAINT = Route("POST /board/{tile_id}/paint")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`app/features/board/data.py`:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
import sqlite3
|
|
284
|
+
from dataclasses import dataclass
|
|
285
|
+
|
|
286
|
+
SCHEMA = """
|
|
287
|
+
CREATE TABLE IF NOT EXISTS tiles (
|
|
288
|
+
id INTEGER PRIMARY KEY,
|
|
289
|
+
color TEXT NOT NULL DEFAULT '#ffffff'
|
|
290
|
+
);
|
|
291
|
+
INSERT OR IGNORE INTO tiles (id) VALUES (1), (2), (3), (4), (5), (6), (7), (8), (9);
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass(frozen=True, slots=True)
|
|
296
|
+
class Tile:
|
|
297
|
+
id: int
|
|
298
|
+
color: str
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def list_tiles(conn: sqlite3.Connection) -> list[Tile]:
|
|
302
|
+
rows = conn.execute("SELECT id, color FROM tiles ORDER BY id").fetchall()
|
|
303
|
+
return [Tile(*row) for row in rows]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def set_color(conn: sqlite3.Connection, tile_id: int, color: str) -> None:
|
|
307
|
+
with conn:
|
|
308
|
+
conn.execute("UPDATE tiles SET color = ? WHERE id = ?", (color, tile_id))
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`app/features/board/views.py`:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
from stario import h
|
|
315
|
+
from stario.datastar import at, data
|
|
316
|
+
from stario.markup import HtmlElement, classes
|
|
317
|
+
|
|
318
|
+
from app.common.shell import page
|
|
319
|
+
from app.features.board.data import Tile
|
|
320
|
+
from app.features.board.urls import BOARD_LIVE, PAINT
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def render(tiles: list[Tile]) -> HtmlElement:
|
|
324
|
+
return page(
|
|
325
|
+
"Board",
|
|
326
|
+
h.Main(
|
|
327
|
+
{"id": "board"},
|
|
328
|
+
data.signals({"color": "#000000"}, if_missing=True),
|
|
329
|
+
data.init(at.get(BOARD_LIVE.href(), retry="always")),
|
|
330
|
+
h.Label("Color", h.Input({"type": "color"}, data.bind("color"))),
|
|
331
|
+
h.Div(
|
|
332
|
+
classes("grid"),
|
|
333
|
+
*[
|
|
334
|
+
h.Button(
|
|
335
|
+
{"style": f"background:{tile.color}", "aria-label": f"Tile {tile.id}"},
|
|
336
|
+
data.on("click", at.post(PAINT.href(tile.id))),
|
|
337
|
+
)
|
|
338
|
+
for tile in tiles
|
|
339
|
+
],
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`app/features/board/handlers.py`:
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
import re
|
|
349
|
+
import sqlite3
|
|
350
|
+
|
|
351
|
+
from stario import App, Context, Relay, Writer
|
|
352
|
+
from stario.datastar import SSE, read_signals
|
|
353
|
+
|
|
354
|
+
from app.features.board import data
|
|
355
|
+
from app.features.board.urls import BOARD, BOARD_LIVE, PAINT
|
|
356
|
+
from app.features.board.views import render
|
|
357
|
+
|
|
358
|
+
CHANGED = "board.changed"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def show_board(conn: sqlite3.Connection):
|
|
362
|
+
async def handler(c: Context, w: Writer) -> None:
|
|
363
|
+
w.html(render(data.list_tiles(conn)))
|
|
364
|
+
|
|
365
|
+
return handler
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def board_live(conn: sqlite3.Connection, relay: Relay[str]):
|
|
369
|
+
async def handler(c: Context, w: Writer) -> None:
|
|
370
|
+
async with relay.subscribe(CHANGED) as events:
|
|
371
|
+
sse = SSE(w)
|
|
372
|
+
sse.patch_elements(render(data.list_tiles(conn)))
|
|
373
|
+
async for _ in c.alive(events):
|
|
374
|
+
sse.patch_elements(render(data.list_tiles(conn)))
|
|
375
|
+
|
|
376
|
+
return handler
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def paint_tile(conn: sqlite3.Connection, relay: Relay[str]):
|
|
380
|
+
async def handler(c: Context, w: Writer) -> None:
|
|
381
|
+
signals = await read_signals(c.req)
|
|
382
|
+
color = str(signals.get("color", ""))
|
|
383
|
+
if not re.fullmatch(r"#[0-9a-fA-F]{6}", color):
|
|
384
|
+
w.text("Color must be #rrggbb", status=400)
|
|
385
|
+
return
|
|
386
|
+
w.empty()
|
|
387
|
+
data.set_color(conn, int(c.match.params["tile_id"]), color)
|
|
388
|
+
relay.publish(CHANGED, "paint")
|
|
389
|
+
|
|
390
|
+
return handler
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def register_board(app: App, conn: sqlite3.Connection, relay: Relay[str]) -> None:
|
|
394
|
+
app.add(BOARD, show_board(conn))
|
|
395
|
+
app.add(BOARD_LIVE, board_live(conn, relay))
|
|
396
|
+
app.add(PAINT, paint_tile(conn, relay))
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`app/main.py`:
|
|
400
|
+
|
|
401
|
+
```python
|
|
402
|
+
import asyncio
|
|
403
|
+
import sqlite3
|
|
404
|
+
|
|
405
|
+
from stario import App, Relay, Span, serve
|
|
406
|
+
|
|
407
|
+
from app.assets import ASSETS
|
|
408
|
+
from app.config import Config
|
|
409
|
+
from app.features.board import data as board_data
|
|
410
|
+
from app.features.board.handlers import register_board
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def bootstrap(app: App, span: Span):
|
|
414
|
+
config = Config.from_env()
|
|
415
|
+
conn = sqlite3.connect(config.db_path)
|
|
416
|
+
conn.executescript(board_data.SCHEMA)
|
|
417
|
+
relay: Relay[str] = Relay()
|
|
418
|
+
|
|
419
|
+
span.attrs(await ASSETS.attach(app))
|
|
420
|
+
register_board(app, conn, relay)
|
|
421
|
+
yield
|
|
422
|
+
conn.close()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
asyncio.run(serve(bootstrap))
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Note the two names called `data`. In `handlers.py`, `data` is the feature's `data.py`. In `views.py`, `data` is `stario.datastar.data`. They do not meet in one file.
|
|
430
|
+
|
|
431
|
+
## Handlers
|
|
432
|
+
|
|
433
|
+
One shape, whatever the count: a factory per handler, and one `register_<screen>(app, deps)` that adds every route in one place. The factory takes the dependencies the handler needs; the register function takes them all. A static screen's factory may take a config value; a live screen's factories take `conn` and `relay`. Do not wrap handlers in a class.
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
def show_about(version: str):
|
|
437
|
+
async def handler(c: Context, w: Writer) -> None:
|
|
438
|
+
w.html(render(version))
|
|
439
|
+
|
|
440
|
+
return handler
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def register_about(app: App, version: str) -> None:
|
|
444
|
+
app.add(ABOUT, show_about(version))
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Inside a handler:
|
|
448
|
+
|
|
449
|
+
- Read input first: `c.match.params`, `c.req.query`, `c.state["user"]`, `await read_signals(c.req)`.
|
|
450
|
+
- Validate. Write the 4xx and `return` when the request is malformed or forbidden.
|
|
451
|
+
- Reply. `w.empty()` once the input validates — the `204` means "accepted". The work may run before or after it; the stream delivers the result.
|
|
452
|
+
- Change state. Publish.
|
|
453
|
+
|
|
454
|
+
A subscribe handler opens the subscription before it publishes anything this connection must see. Cleanup after `async for` runs on disconnect and on shutdown.
|
|
455
|
+
|
|
456
|
+
## Code shape
|
|
457
|
+
|
|
458
|
+
Fewer functions, not thinner ones. The work lives where you can read it.
|
|
459
|
+
|
|
460
|
+
- Inline a function whose body is one call: `return other(...)`, one `sse.patch_elements(...)`, one `w.html(...)`.
|
|
461
|
+
- Inline a private alias that exists to rename a call.
|
|
462
|
+
- Do not extract a helper because a line is long. Do not extract a helper for one call site.
|
|
463
|
+
- Keep a function when it has two or more call sites, when it is a loop body, or when it does I/O you want to test alone. Branches alone do not earn a name. One call site inlines, branches included.
|
|
464
|
+
- Do not add a backwards-compatible alias. Rename, then fix the callers.
|
|
465
|
+
- No comments that restate the code. A comment explains a non-obvious reason.
|
|
466
|
+
- Name query functions after the SQL verb: `list_*` returns many, `get_*` returns one or `None`, `add_*`, `set_*`, `remove_*` mutate.
|
|
467
|
+
- Delete code with its screen. A removed page leaves no folder, no route, no test file.
|
|
468
|
+
|
|
469
|
+
## Tests
|
|
470
|
+
|
|
471
|
+
Tests run against the production `bootstrap` through `TestClient`, which speaks the real HTTP protocol on an in-memory transport. There is no second composition root.
|
|
472
|
+
|
|
473
|
+
`tests/conftest.py`:
|
|
474
|
+
|
|
475
|
+
```python
|
|
476
|
+
import pytest
|
|
477
|
+
from stario.testing import TestClient
|
|
478
|
+
|
|
479
|
+
from app.main import bootstrap
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@pytest.fixture
|
|
483
|
+
async def client(tmp_path, monkeypatch):
|
|
484
|
+
monkeypatch.setenv("APP_DB_PATH", str(tmp_path / "app.sqlite3"))
|
|
485
|
+
async with TestClient(bootstrap) as c:
|
|
486
|
+
yield c
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Each screen has one test file with two kinds of tests:
|
|
490
|
+
|
|
491
|
+
1. The page loads. `GET` the route, assert `200`, assert text the page must show.
|
|
492
|
+
2. A command repaints the stream. Open the stream, send the command, read the next event.
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
from app.features.board.urls import BOARD, BOARD_LIVE, PAINT
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def test_board_loads(client):
|
|
499
|
+
r = await client.get(BOARD.href())
|
|
500
|
+
assert r.status_code == 200
|
|
501
|
+
assert 'id="board"' in r.text
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def test_paint_repaints_the_stream(client):
|
|
505
|
+
headers = {"accept-encoding": "identity"}
|
|
506
|
+
async with client.stream("GET", BOARD_LIVE.href(), headers=headers) as live:
|
|
507
|
+
events = live.iter_events()
|
|
508
|
+
first = await anext(events)
|
|
509
|
+
assert 'id="board"' in first["data"]
|
|
510
|
+
|
|
511
|
+
r = await client.post(PAINT.href(1), json={"color": "#ff0000"})
|
|
512
|
+
assert r.status_code == 204
|
|
513
|
+
|
|
514
|
+
repaint = await anext(events)
|
|
515
|
+
assert "#ff0000" in repaint["data"]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
async def test_paint_rejects_bad_color(client):
|
|
519
|
+
r = await client.post(PAINT.href(1), json={"color": "red"})
|
|
520
|
+
assert r.status_code == 400
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`client.post(url, json=...)` sends the JSON object as Datastar signals. `client.stream(...)` yields once headers are in. `iter_events()` yields SSE events as dicts with `event` and `data`. Leaving the `async with` disconnects the stream and ends the handler. The client keeps a cookie jar, so a login response sets the cookie for the next request.
|
|
524
|
+
|
|
525
|
+
Count the events. A stream that publishes on join (presence) repaints once more before the command repaint. Read that event first, then send the command. A command that replies `SSE(w).navigate(url)` returns `200` with `text/event-stream`; assert `"window.location" in r.text` and the target path.
|
|
526
|
+
|
|
527
|
+
Test the data layer directly only when a query has logic of its own. The `client` fixture points `APP_DB_PATH` at `tmp_path`, and `Config.from_env()` runs inside `bootstrap`, so each test gets its own file.
|
|
528
|
+
|
|
529
|
+
## Common mistakes
|
|
530
|
+
|
|
531
|
+
| Avoid | Prefer |
|
|
532
|
+
| --- | --- |
|
|
533
|
+
| Flat `app/routes.py`, `app/handlers.py` | `app/features/<screen>/` from the first commit |
|
|
534
|
+
| A folder named after a layer (`app/views/`) | A folder named after a screen (`app/features/settings/`) |
|
|
535
|
+
| FastAPI, Starlette, ASGI response objects | `App`, `Route`, `Context`, `Writer` (`w.html`, `w.empty`, `w.redirect`) |
|
|
536
|
+
| `app.get(path, handler)` | `Route("GET /path")` and `app.add(route, handler)` |
|
|
537
|
+
| `url_for(...)` | `ROUTE.href(...)` |
|
|
538
|
+
| `bootstrap` without `yield` | One async generator with one `yield` |
|
|
539
|
+
| A command that returns HTML | A command that replies 204 and publishes |
|
|
540
|
+
| `<form method="post">` | `data.on("submit", at.post(...), prevent=True)` |
|
|
541
|
+
| `patch_elements(fragment, selector=...)` as the first version | `patch_elements(render(state))` for the whole document |
|
|
542
|
+
| A signal that holds a row, a list, an id, or a signed-in user | The row in the store, the id in the URL, the user in the cookie |
|
|
543
|
+
| `{"open": True}` rendered for a user-opened dialog | `data.preserve_attr("open")` |
|
|
544
|
+
| A `.js` file for a click | `data.on("click", "...")` on the element |
|
|
545
|
+
| `Writer.alive(...)` | `c.alive(...)` |
|
|
546
|
+
| Bare `asyncio.create_task(...)` | `c.app.create_task(...)` |
|
|
547
|
+
| `json.dumps` for signals | `stario.json` through `SSE` and `w.json` |
|
|
548
|
+
| A helper function per element | One `render` with the tree written inline |
|
|
549
|
+
| Shared `components.py` across features on day one | Each feature keeps its own HTML; merge after it proves stable |
|
|
550
|
+
| A second bootstrap for tests | `TestClient(bootstrap)` |
|
|
551
|
+
| Python 3.12 idioms | Python 3.14: `type` aliases, PEP 695 generics, `match`, `dataclass(slots=True)` |
|
|
552
|
+
|
|
553
|
+
## Not in this guide
|
|
554
|
+
|
|
555
|
+
Each of these has a home. Do not invent a pattern; read the source named here.
|
|
556
|
+
|
|
557
|
+
- User identity, login, logout, protected prefixes: skill `stario-auth`.
|
|
558
|
+
- SQLite with one writer thread and `watch`: skill `stario-litewriter`.
|
|
559
|
+
- Tailwind and DaisyUI without Node: skill `stario-tailwind-daisyui`.
|
|
560
|
+
- Adding or removing a screen, step by step: skill `stario-feature`.
|
|
561
|
+
- File uploads and user files: https://stario.dev/docs/how-tos/user-uploads-and-storage
|
|
562
|
+
- Error pages and `app.not_found`: https://stario.dev/docs/how-tos/error-handlers
|
|
563
|
+
- Deployment, containers, TLS: https://stario.dev/docs/how-tos/deployment-containers-and-tls
|
|
564
|
+
- Telemetry and the SQLite tracer: https://stario.dev/docs/how-tos/traces-in-sqlite
|
|
565
|
+
- Datastar attribute reference: https://data-star.dev
|
|
566
|
+
|
|
567
|
+
Install the skills with `stario-create skills install`, or `npx skills add bobowski/stario-create`, or copy a `skills/<name>/` folder from https://github.com/bobowski/stario-create into `.agents/skills/`.
|
|
568
|
+
|
|
569
|
+
## This app
|
|
570
|
+
|
|
571
|
+
Notes for this app go below this line. The text above is the Stario guide. `stario-create guide --check` tells you when it is stale; `stario-create guide --write` replaces the text above this section and keeps your notes.
|