From ed03aeeb73699cfe7049acd5c39b9cb37c5736a9 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 15:57:07 -0300 Subject: [PATCH 01/22] feat: introduce Alias class for handling request parameter aliases. close #937 --- fasthtml/_modidx.py | 1 + fasthtml/core.py | 41 +++++++++++++++++++------------ nbs/api/00_core.ipynb | 57 ++++++++++++++++++++++++++----------------- 3 files changed, 61 insertions(+), 38 deletions(-) diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 8eb779a0..2a623f94 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -29,6 +29,7 @@ 'fasthtml.core.APIRouter._wrap_func': ('api/core.html#apirouter._wrap_func', 'fasthtml/core.py'), 'fasthtml.core.APIRouter.to_app': ('api/core.html#apirouter.to_app', 'fasthtml/core.py'), 'fasthtml.core.APIRouter.ws': ('api/core.html#apirouter.ws', 'fasthtml/core.py'), + 'fasthtml.core.Alias': ('api/core.html#alias', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn': ('api/core.html#apireturn', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn.__bool__': ('api/core.html#apireturn.__bool__', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn.__call__': ('api/core.html#apireturn.__call__', 'fasthtml/core.py'), diff --git a/fasthtml/core.py b/fasthtml/core.py index e1539238..2dfa418f 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -18,7 +18,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. -Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: +Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: req, ws the Request / WebSocket connection sess session dict @@ -48,11 +48,11 @@ class Cards: ... # class route group: each verb-named method (get, patch, . __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', - 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', - 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', - 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', 'RouteFuncs', - 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', - 'MiddlewareBase', 'FtResponse', 'unqid'] + 'JSONResponse', 'Alias', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', + 'flat_tuple', 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', + 'FastHTML', 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', + 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', + 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] # %% ../nbs/api/00_core.ipynb #23503b9e import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess @@ -64,7 +64,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from fastcore.style import S from types import UnionType, SimpleNamespace as ns, GenericAlias -from typing import get_args, get_origin, Union, Mapping, List, Any, Callable +from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated from datetime import datetime,date from dataclasses import dataclass from inspect import Parameter,get_annotations @@ -257,8 +257,19 @@ def _default(o): return list(o) if is_listy(o) else str(o) # %% ../nbs/api/00_core.ipynb #5fa96e3a +@dataclass(frozen=True) +class Alias: + "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" + name: str + async def _find_p(conn, data, hdrs, arg:str, p:Parameter): "In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)" + alias = None + if get_origin(p.annotation) is Annotated: + anno, *meta = get_args(p.annotation) + p = p.replace(annotation=anno) + alias = next((m.name for m in meta if isinstance(m, Alias)), None) + key = alias or arg anno = p.annotation # Special annotation types if isinstance(anno, type) and not isinstance(anno, GenericAlias): @@ -289,17 +300,17 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) return None # Not a special name or a special annotation - res = conn.path_params.get(arg, None) - if res in (empty,None): res = conn.cookies.get(arg, None) - if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None) - if res in (empty,None): res = conn.query_params.getlist(arg) + res = conn.path_params.get(key, None) + if res in (empty,None): res = conn.cookies.get(key, None) + if res in (empty,None): res = hdrs.get(alias or snake2hyphens(arg), None) + if res in (empty,None): res = conn.query_params.getlist(key) if res==[]: res = None - if res in (empty,None): res = data.get(arg, None) - if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(arg) # htmx v4 nests form data under `values` + if res in (empty,None): res = data.get(key, None) + if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(key) # htmx v4 nests form data under `values` if res in (empty,None): if p.default is empty: - if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") - raise ValueError(f"Missing required field: {arg}") + if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {key}") + raise ValueError(f"Missing required field: {key}") res = p.default try: return _fix_anno(anno, res) except ValueError as e: diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 1fa7a1c4..f16e791c 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -43,7 +43,7 @@ "\n", "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", "\n", - "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", + "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", "\n", " req, ws the Request / WebSocket connection\n", " sess session dict\n", @@ -99,7 +99,7 @@ "from fastcore.style import S\n", "\n", "from types import UnionType, SimpleNamespace as ns, GenericAlias\n", - "from typing import get_args, get_origin, Union, Mapping, List, Any, Callable\n", + "from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated\n", "from datetime import datetime,date\n", "from dataclasses import dataclass\n", "from inspect import Parameter,get_annotations\n", @@ -895,8 +895,19 @@ "outputs": [], "source": [ "#| export\n", + "@dataclass(frozen=True)\n", + "class Alias:\n", + " \"Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`\"\n", + " name: str\n", + "\n", "async def _find_p(conn, data, hdrs, arg:str, p:Parameter):\n", " \"In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)\"\n", + " alias = None\n", + " if get_origin(p.annotation) is Annotated:\n", + " anno, *meta = get_args(p.annotation)\n", + " p = p.replace(annotation=anno)\n", + " alias = next((m.name for m in meta if isinstance(m, Alias)), None)\n", + " key = alias or arg\n", " anno = p.annotation\n", " # Special annotation types\n", " if isinstance(anno, type) and not isinstance(anno, GenericAlias):\n", @@ -927,17 +938,17 @@ " if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower())\n", " return None\n", " # Not a special name or a special annotation\n", - " res = conn.path_params.get(arg, None)\n", - " if res in (empty,None): res = conn.cookies.get(arg, None)\n", - " if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None)\n", - " if res in (empty,None): res = conn.query_params.getlist(arg)\n", + " res = conn.path_params.get(key, None)\n", + " if res in (empty,None): res = conn.cookies.get(key, None)\n", + " if res in (empty,None): res = hdrs.get(alias or snake2hyphens(arg), None)\n", + " if res in (empty,None): res = conn.query_params.getlist(key)\n", " if res==[]: res = None\n", - " if res in (empty,None): res = data.get(arg, None)\n", - " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(arg) # htmx v4 nests form data under `values`\n", + " if res in (empty,None): res = data.get(key, None)\n", + " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(key) # htmx v4 nests form data under `values`\n", " if res in (empty,None):\n", " if p.default is empty:\n", - " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {arg}\")\n", - " raise ValueError(f\"Missing required field: {arg}\")\n", + " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {key}\")\n", + " raise ValueError(f\"Missing required field: {key}\")\n", " res = p.default\n", " try: return _fix_anno(anno, res)\n", " except ValueError as e:\n", @@ -945,19 +956,6 @@ " raise" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "bf42edad", - "metadata": {}, - "outputs": [], - "source": [ - "#| export\n", - "async def _find_ps(conn, data, hdrs, params):\n", - " if conn.query_params: data |= dict(conn.query_params)\n", - " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" - ] - }, { "cell_type": "code", "execution_count": null, @@ -980,6 +978,19 @@ "await _from_body(req, p, data)" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf42edad", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "async def _find_ps(conn, data, hdrs, params):\n", + " if conn.query_params: data |= dict(conn.query_params)\n", + " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" + ] + }, { "cell_type": "code", "execution_count": null, From 19430ad836c252b196831c639d725151ca0ca000 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:18:20 -0300 Subject: [PATCH 02/22] test: add tests for request parameter alias handling --- tests/test_alias.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_alias.py diff --git a/tests/test_alias.py b/tests/test_alias.py new file mode 100644 index 00000000..059fe82b --- /dev/null +++ b/tests/test_alias.py @@ -0,0 +1,42 @@ +from fasthtml.common import * +from starlette.testclient import TestClient +from typing import Annotated + +app, rt = fast_app() +cli = TestClient(app) + +@rt("/query") +def get(user_id: Annotated[str, Alias("user-id")]): return f"q:{user_id}" + +@rt("/form", methods=["post"]) +def post(user_id: Annotated[str, Alias("user-id")]): return f"f:{user_id}" + +@rt("/path/{item_id}") +def get(item_id: Annotated[int, Alias("item_id")]): return f"p:{item_id}" + +@rt("/header") +def get(x_api_key: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key}" + +@rt("/cookie") +def get(req, session_id: Annotated[str, Alias("session-id")]): return f"c:{session_id}" + +@rt("/int") +def get(count: Annotated[int, Alias("the-count")]): return f"i:{count + 1}" + +@rt("/plain") +def get(user_id: str): return f"plain:{user_id}" + +@rt("/missing") +def get(user_id: Annotated[str, Alias("user-id")]): return user_id + +def test_query_alias(): assert cli.get('/query?user-id=abc').text == 'q:abc' +def test_form_alias(): assert cli.post('/form', data={'user-id': 'abc'}).text == 'f:abc' +def test_path_alias(): assert cli.get('/path/7').text == 'p:7' +def test_header_alias(): assert cli.get('/header', headers={'X-Api-Key': 'k1'}).text == 'h:k1' +def test_cookie_alias(): assert cli.get('/cookie', cookies={'session-id': 's1'}).text == 'c:s1' +def test_int_alias(): assert cli.get('/int?the-count=4').text == 'i:5' +def test_plain_unchanged(): assert cli.get('/plain?user_id=z').text == 'plain:z' +def test_snake_name_not_used_for_alias(): assert cli.get('/query?user_id=abc').status_code == 400 +def test_missing_message_uses_alias(): + r = cli.get('/missing') + assert r.status_code == 400 and 'Missing required field: user-id' in r.text From 4e239aeabea765f95dfb3cbdb1349840212635a1 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:43:08 -0300 Subject: [PATCH 03/22] refactor: update code cell metadata in 00_core.ipynb --- nbs/api/00_core.ipynb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index f16e791c..dee11489 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1,14 +1,12 @@ { "cells": [ { - "cell_type": "code", - "execution_count": null, - "id": "fa505c58", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "#| default_exp core" - ] + "execution_count": null, + "source": "#| default_exp core", + "id": "2ae074a97d562882" }, { "cell_type": "markdown", From 2828e31f23259e6f85f64336cfd5f5cc98d98eea Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 17:06:04 -0300 Subject: [PATCH 04/22] refactor: update code cell structure --- nbs/api/00_core.ipynb | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index dee11489..ebc2c45c 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1,12 +1,14 @@ { "cells": [ { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "#| default_exp core", - "id": "2ae074a97d562882" + "id": "fa505c58", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp core" + ] }, { "cell_type": "markdown", @@ -954,6 +956,19 @@ " raise" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf42edad", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "async def _find_ps(conn, data, hdrs, params):\n", + " if conn.query_params: data |= dict(conn.query_params)\n", + " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" + ] + }, { "cell_type": "code", "execution_count": null, @@ -976,19 +991,6 @@ "await _from_body(req, p, data)" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "bf42edad", - "metadata": {}, - "outputs": [], - "source": [ - "#| export\n", - "async def _find_ps(conn, data, hdrs, params):\n", - " if conn.query_params: data |= dict(conn.query_params)\n", - " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" - ] - }, { "cell_type": "code", "execution_count": null, From 71b3e941f8e96aa0d713fed61e80d0a5146ba92f Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 15:57:07 -0300 Subject: [PATCH 05/22] feat: introduce Alias class for handling request parameter aliases. close #937 --- fasthtml/_modidx.py | 1 + fasthtml/core.py | 43 +- nbs/api/00_core.ipynb | 3066 +++++++++++++++-------------------------- 3 files changed, 1164 insertions(+), 1946 deletions(-) diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 8eb779a0..2a623f94 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -29,6 +29,7 @@ 'fasthtml.core.APIRouter._wrap_func': ('api/core.html#apirouter._wrap_func', 'fasthtml/core.py'), 'fasthtml.core.APIRouter.to_app': ('api/core.html#apirouter.to_app', 'fasthtml/core.py'), 'fasthtml.core.APIRouter.ws': ('api/core.html#apirouter.ws', 'fasthtml/core.py'), + 'fasthtml.core.Alias': ('api/core.html#alias', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn': ('api/core.html#apireturn', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn.__bool__': ('api/core.html#apireturn.__bool__', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn.__call__': ('api/core.html#apireturn.__call__', 'fasthtml/core.py'), diff --git a/fasthtml/core.py b/fasthtml/core.py index 58c00f4c..894096cb 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -18,7 +18,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. -Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: +Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: req, ws the Request / WebSocket connection sess session dict @@ -48,11 +48,11 @@ class Cards: ... # class route group: each verb-named method (get, patch, . __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', - 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', - 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', - 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', 'RouteFuncs', - 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', - 'MiddlewareBase', 'FtResponse', 'unqid'] + 'JSONResponse', 'Alias', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', + 'flat_tuple', 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', + 'FastHTML', 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', + 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', + 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] # %% ../nbs/api/00_core.ipynb #23503b9e import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess @@ -64,7 +64,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from fastcore.style import S from types import UnionType, SimpleNamespace as ns, GenericAlias -from typing import get_args, get_origin, Union, Mapping, List, Any, Callable +from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated from datetime import datetime,date from dataclasses import dataclass from inspect import Parameter,get_annotations @@ -259,8 +259,19 @@ def _default(o): return list(o) if is_listy(o) else str(o) # %% ../nbs/api/00_core.ipynb #5fa96e3a +@dataclass(frozen=True) +class Alias: + "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" + name: str + async def _find_p(conn, data, hdrs, arg:str, p:Parameter): "In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)" + alias = None + if get_origin(p.annotation) is Annotated: + anno, *meta = get_args(p.annotation) + p = p.replace(annotation=anno) + alias = next((m.name for m in meta if isinstance(m, Alias)), None) + key = alias or arg anno = p.annotation # Special annotation types if isinstance(anno, type) and not isinstance(anno, GenericAlias): @@ -270,7 +281,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if issubclass(anno, State): return conn.scope['app'].state if anno is dict: return data if _is_body(anno): - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) return await _from_body(conn, p, data) if (msg := _check_anno(arg, anno)): return warn(msg) # Special param names with no annotations @@ -291,16 +302,16 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) return None # Not a special name or a special annotation - res = conn.path_params.get(arg, None) - if res in (empty,None): res = conn.cookies.get(arg, None) - if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None) - if res in (empty,None): res = _formitem(conn.query_params, arg) - if res in (empty,None): res = _formitem(data, arg) - if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, arg) # htmx v4 nests form data under `values` + res = conn.path_params.get(key, None) + if res in (empty,None): res = conn.cookies.get(key, None) + if res in (empty,None): res = hdrs.get(alias or snake2hyphens(key), None) + if res in (empty,None): res = _formitem(conn.query_params, key) + if res in (empty,None): res = _formitem(data, key) + if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` if res in (empty,None): if p.default is empty: - if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") - raise ValueError(f"Missing required field: {arg}") + if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {key}") + raise ValueError(f"Missing required field: {key}") res = p.default try: return _fix_anno(anno, res) except ValueError as e: diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 9238c3a0..95a8cf01 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1,28 +1,25 @@ { "cells": [ { - "cell_type": "code", - "execution_count": null, - "id": "fa505c58", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "#| default_exp core" - ] + "execution_count": null, + "source": "#| default_exp core", + "id": "7f686573b69acc40" }, { - "cell_type": "markdown", - "id": "2161d9b5", "metadata": {}, + "cell_type": "markdown", "source": [ "# Core\n", "> The `FastHTML` subclass of `Starlette`." - ] + ], + "id": "59798c88b0200b3c" }, { - "cell_type": "markdown", - "id": "1f8f308f", "metadata": {}, + "cell_type": "markdown", "source": [ "#| export\n", "Create an app with `app = FastHTML()`. The `@rt` decorator adds routes (`rt = app.route`), inferring what it can from the function:\n", @@ -43,7 +40,7 @@ "\n", "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", "\n", - "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", + "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", "\n", " req, ws the Request / WebSocket connection\n", " sess session dict\n", @@ -64,30 +61,26 @@ "`before`/`after` functions (app-level, with `Beforeware` skip patterns, or per-route `before=`) get the same param injection as handlers. A `before` returning a value responds with it and skips the handler (the usual auth-guard pattern).\n", "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." - ] + ], + "id": "ab4062d704000f87" }, { - "cell_type": "markdown", - "id": "46e2e6e8", "metadata": {}, - "source": [ - "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/)." - ] + "cell_type": "markdown", + "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", + "id": "1acd20a02bd6d35a" }, { - "cell_type": "markdown", - "id": "568b20cd", "metadata": {}, - "source": [ - "## Imports and utils" - ] + "cell_type": "markdown", + "source": "## Imports and utils", + "id": "34bfc48a8b184299" }, { - "cell_type": "code", - "execution_count": null, - "id": "23503b9e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess\n", @@ -99,7 +92,7 @@ "from fastcore.style import S\n", "\n", "from types import UnionType, SimpleNamespace as ns, GenericAlias\n", - "from typing import get_args, get_origin, Union, Mapping, List, Any, Callable\n", + "from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated\n", "from datetime import datetime,date\n", "from dataclasses import dataclass\n", "from inspect import Parameter,get_annotations\n", @@ -116,14 +109,14 @@ "\n", "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" - ] + ], + "id": "53afb6589c3d143e" }, { - "cell_type": "code", - "execution_count": null, - "id": "7f5d0a72", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "import time, tempfile\n", "\n", @@ -137,125 +130,84 @@ "from fastcore.test import *\n", "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" - ] + ], + "id": "7410b06288e1fe26" }, { - "cell_type": "code", - "execution_count": null, - "id": "19d3f2a7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _params(f): return signature_ex(f, True).parameters\n", "\n", "empty = Parameter.empty" - ] + ], + "id": "43c1dfb348886a28" }, { - "cell_type": "markdown", - "id": "19ffa7e0", "metadata": {}, - "source": [ - "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern." - ] + "cell_type": "markdown", + "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", + "id": "994c8a6af6a4d489" }, { - "cell_type": "code", - "execution_count": null, - "id": "e68a76c9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def parsed_date(s:str):\n", " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" - ] + ], + "id": "459b5fc44b7c9d5e" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5331a3e7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "datetime.datetime(2026, 3, 30, 14, 0)" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "parsed_date('2pm')" - ] + "source": "parsed_date('2pm')", + "id": "809da9c50103c9dd" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "c40c9071", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "isinstance(date.fromtimestamp(0), date)" - ] + "source": "isinstance(date.fromtimestamp(0), date)", + "id": "5e5a75f6663c14e2" }, { - "cell_type": "code", - "execution_count": null, - "id": "7c820373", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def snake2hyphens(s:str):\n", " \"Convert `s` from snake case to hyphenated and capitalised\"\n", " s = snake2camel(s)\n", " return camel2words(s, '-')" - ] + ], + "id": "fd499ac1bf5ca80c" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "442a5aac", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Snake-Case'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "snake2hyphens(\"snake_case\")" - ] + "source": "snake2hyphens(\"snake_case\")", + "id": "788eebb4a93cd144" }, { - "cell_type": "code", - "execution_count": null, - "id": "25f3b8f8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "htmx_hdrs = dict(\n", @@ -281,14 +233,14 @@ "def _get_htmx(h):\n", " res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" - ] + ], + "id": "5c5266880608b6d1" }, { - "cell_type": "code", - "execution_count": null, - "id": "5b4b5d95", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def test_request(url: str='/', headers: dict=None, method: str='get')->Request:\n", " if headers is None: headers={}\n", @@ -296,66 +248,53 @@ " client=('127.0.0.1', 8000), server=('127.0.0.1', 8000))\n", " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" - ] + ], + "id": "a223b8b2def6c12f" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "36e2cac0", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "HtmxHeaders(boosted=None, current_url=None, history_restore_request=None, prompt=None, request='1', request_type=None, source=None, target=None, trigger_name=None, trigger=None)" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" - ] + ], + "id": "76cb9df3f9560f26" }, { - "cell_type": "code", - "execution_count": null, - "id": "1d53e8e7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" - ] + ], + "id": "72e3945ec1dcb7ed" }, { - "cell_type": "markdown", - "id": "ef18f76b", "metadata": {}, - "source": [ - "## Request and response" - ] + "cell_type": "markdown", + "source": "## Request and response", + "id": "13f0432b5d256410" }, { - "cell_type": "code", - "execution_count": null, - "id": "5ab74473", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "fh_cfg = AttrDict(indent=True)" - ] + ], + "id": "64dbed7ee2309943" }, { - "cell_type": "code", - "execution_count": null, - "id": "88d12e68", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "_special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'}\n", @@ -364,14 +303,14 @@ " \"Check for common annotation issues; returns warning string or None\"\n", " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" - ] + ], + "id": "174eac262617a991" }, { - "cell_type": "code", - "execution_count": null, - "id": "0afb520c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _fix_anno(t, o):\n", @@ -385,14 +324,14 @@ " if isinstance(t, type) and issubclass(t, (list,tuple)): return None\n", " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" - ] + ], + "id": "b61bf0b9981ede39" }, { - "cell_type": "code", - "execution_count": null, - "id": "95b1f5c9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_eq(_fix_anno(Union[str,None], 'a'), 'a')\n", "test_eq(_fix_anno(float, 0.9), 0.9)\n", @@ -406,14 +345,14 @@ "test_eq(_fix_anno(tuple, ['1','2']), None)\n", "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" - ] + ], + "id": "1734c816b8331d79" }, { - "cell_type": "code", - "execution_count": null, - "id": "c58ccadb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _form_arg(k, v, d):\n", @@ -424,76 +363,63 @@ " anno = d.get(k, None)\n", " if not anno: return v\n", " return _fix_anno(anno, v)" - ] + ], + "id": "d1cd459b43ebdeb8" }, { - "cell_type": "code", - "execution_count": null, - "id": "59757d76", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "d = dict(k=int, l=List[int])\n", "test_eq(_form_arg('k', \"1\", d), 1)\n", "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" - ] + ], + "id": "c56632e237956f69" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fc04751", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@dataclass\n", "class HttpHeader:\n", " \"A response header as a k,v pair\"\n", " k:str;v:str" - ] + ], + "id": "e02eb8ef77a0ed27" }, { - "cell_type": "code", - "execution_count": null, - "id": "94e18161", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()\n", "\n", "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" - ] + ], + "id": "20c90293417cb2af" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "592d6c8e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'HX-Trigger-After-Settle'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "_to_htmx_header('trigger_after_settle')" - ] + "source": "_to_htmx_header('trigger_after_settle')", + "id": "64616ebfdd4b64bb" }, { - "cell_type": "code", - "execution_count": null, - "id": "f6a2e62e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@use_kwargs_dict(**htmx_resps)\n", @@ -501,105 +427,70 @@ " \"HTMX response headers\"\n", " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" - ] + ], + "id": "96914eebba4f02bc" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "e89857eb", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "HttpHeader(k='HX-Trigger-After-Settle', v='hi')" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "HtmxResponseHeaders(trigger_after_settle='hi')" - ] + "source": "HtmxResponseHeaders(trigger_after_settle='hi')", + "id": "2189b2baeb94dd0d" }, { - "cell_type": "code", - "execution_count": null, - "id": "56cc589f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _annotations(anno):\n", " \"Same as `get_annotations`, but also works on namedtuples\"\n", " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" - ] + ], + "id": "d91ef3bd135dc3ee" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5453a684", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'a': int, 'b': str}" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@dataclass\n", "class Foo: a:int; b:str\n", "_annotations(Foo)" - ] + ], + "id": "d4be2f3056e3f950" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bcad3a5a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'x': str, 'y': str}" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" - ] + ], + "id": "d2afd93effac57ad" }, { - "cell_type": "code", - "execution_count": null, - "id": "cb4ed4aa", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" - ] + ], + "id": "ffd54d552801d9a5" }, { - "cell_type": "code", - "execution_count": null, - "id": "ffdde66f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _formitem(form, k):\n", @@ -608,53 +499,53 @@ " if isinstance(form, dict): return form.get(k)\n", " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" - ] + ], + "id": "9008844db08f88f2" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fe74444", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def form2dict(form: FormData) -> dict:\n", " \"Convert starlette form data to a dict\"\n", " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" - ] + ], + "id": "cff090e30c5ca11a" }, { - "cell_type": "code", - "execution_count": null, - "id": "cf80ea34", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "d = [('a',1),('a',2),('b',0)]\n", "fd = FormData(d)\n", "res = form2dict(fd)\n", "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" - ] + ], + "id": "8e046d5aa0ad19ec" }, { - "cell_type": "code", - "execution_count": null, - "id": "8899bc0a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "DEF_MAXPART = 100*1024*1024" - ] + ], + "id": "319a6c62cc3e35ed" }, { - "cell_type": "code", - "execution_count": null, - "id": "42c9cea0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def parse_form(req: Request) -> FormData:\n", @@ -674,50 +565,48 @@ " if not isinstance(res, dict): raise HTTPException(400, \"JSON body must be an object\")\n", " return res\n", " return await req.form(max_part_size=maxpart)" - ] + ], + "id": "9c5cdcc778cc38a7" }, { - "cell_type": "markdown", - "id": "437263a3", "metadata": {}, - "source": [ - "Use the special `__from_request__` class method for custom creation:" - ] + "cell_type": "markdown", + "source": "Use the special `__from_request__` class method for custom creation:", + "id": "af8b65337d831e72" }, { - "cell_type": "code", - "execution_count": null, - "id": "8235f17f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "class Foo(BasicRepr):\n", " def __init__(self, d): self.d = d\n", "\n", " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" - ] + ], + "id": "a9a4f860d46ba408" }, { - "cell_type": "code", - "execution_count": null, - "id": "a4873835", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "p = Parameter('a', Parameter.POSITIONAL_OR_KEYWORD, annotation=Foo)\n", "\n", "req = test_request(method='POST')\n", "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" - ] + ], + "id": "dd2182af86fb2578" }, { - "cell_type": "code", - "execution_count": null, - "id": "0caedd04", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _from_body(conn, p, data):\n", @@ -734,14 +623,14 @@ " if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data\n", " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" - ] + ], + "id": "23f9fadd08c3c28c" }, { - "cell_type": "code", - "execution_count": null, - "id": "88b6da3f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class ApiReturn:\n", @@ -751,22 +640,20 @@ " def __init__(self, isapi=False): self.isapi = isapi\n", " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" - ] + ], + "id": "afa2e38d1b854c81" }, { - "cell_type": "markdown", - "id": "abfc3145", "metadata": {}, - "source": [ - "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints." - ] + "cell_type": "markdown", + "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", + "id": "ec73cbbd214917ea" }, { - "cell_type": "code", - "execution_count": null, - "id": "7cc39ba9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class JSONResponse(JSONResponseOrig):\n", @@ -775,22 +662,14 @@ " def _default(o): return list(o) if is_listy(o) else str(o)\n", " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" - ] + ], + "id": "6fc0c7f6b7e307fe" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "2a8b10f4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'k': 'value1', 'v': 'value3'}\n" - ] - } - ], "source": [ "async def f(req):\n", " def _f(p:HttpHeader): ...\n", @@ -804,14 +683,14 @@ "d = dict(k='value1',v=['value2','value3'])\n", "response = client.post('/', data=d)\n", "print(response.json())" - ] + ], + "id": "91a217829d3e79f9" }, { - "cell_type": "code", - "execution_count": null, - "id": "301e06eb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# An empty `application/json` body yields `{}` (like the empty-multipart guard), not a 500\n", "async def _empty_json(req): return JSONResponse(form2dict(await parse_form(req)))\n", @@ -821,84 +700,80 @@ "test_eq(res.json(), {})\n", "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" - ] + ], + "id": "126d0dcf0eb89218" }, { - "cell_type": "markdown", - "id": "18a9e366", "metadata": {}, - "source": [ - "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:" - ] + "cell_type": "markdown", + "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", + "id": "bd7f8488c0910f52" }, { - "cell_type": "code", - "execution_count": null, - "id": "28bfbbc1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "bad = b'--xyzXX\\r\\nContent-Disposition: form-data; name=\"a\"\\r\\n\\r\\n1\\r\\n--xyz--\\r\\n'\n", "res = client.post('/', content=bad, headers={'content-type':'multipart/form-data; boundary=xyz'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ] + ], + "id": "71c4ef54948643a3" }, { - "cell_type": "markdown", - "id": "6103784b", "metadata": {}, - "source": [ - "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:" - ] + "cell_type": "markdown", + "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", + "id": "f785db5c4a5d10f2" }, { - "cell_type": "code", - "execution_count": null, - "id": "2aaa8fbe", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "res = client.post('/', json=[1,2])\n", "test_eq(res.status_code, 400)\n", "res = client.post('/', content=b'not json', headers={'content-type':'application/json'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ] + ], + "id": "2079e9d02dd2e49c" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "9246153f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "\"['1', '2']\"" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "async def f(req): return Response(str(req.query_params.getlist('x')))\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" - ] + ], + "id": "bc54b0a9ba395e42" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fa96e3a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", + "@dataclass(frozen=True)\n", + "class Alias:\n", + " \"Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`\"\n", + " name: str\n", + "\n", "async def _find_p(conn, data, hdrs, arg:str, p:Parameter):\n", " \"In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)\"\n", + " alias = None\n", + " if get_origin(p.annotation) is Annotated:\n", + " anno, *meta = get_args(p.annotation)\n", + " p = p.replace(annotation=anno)\n", + " alias = next((m.name for m in meta if isinstance(m, Alias)), None)\n", + " key = alias or arg\n", " anno = p.annotation\n", " # Special annotation types\n", " if isinstance(anno, type) and not isinstance(anno, GenericAlias):\n", @@ -929,85 +804,66 @@ " if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower())\n", " return None\n", " # Not a special name or a special annotation\n", - " res = conn.path_params.get(arg, None)\n", - " if res in (empty,None): res = conn.cookies.get(arg, None)\n", + " res = conn.path_params.get(key, None)\n", + " if res in (empty,None): res = conn.cookies.get(key, None)\n", " if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None)\n", - " if res in (empty,None): res = _formitem(conn.query_params, arg)\n", - " if res in (empty,None): res = _formitem(data, arg)\n", - " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, arg) # htmx v4 nests form data under `values`\n", + " if res in (empty,None): res = _formitem(conn.query_params, key)\n", + " if res in (empty,None): res = _formitem(data, key)\n", + " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values`\n", " if res in (empty,None):\n", " if p.default is empty:\n", - " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {arg}\")\n", - " raise ValueError(f\"Missing required field: {arg}\")\n", + " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {key}\")\n", + " raise ValueError(f\"Missing required field: {key}\")\n", " res = p.default\n", " try: return _fix_anno(anno, res)\n", " except ValueError as e:\n", " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" - ] + ], + "id": "8e679f81c025a2e6" }, { - "cell_type": "code", - "execution_count": null, - "id": "bf42edad", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _find_ps(conn, data, hdrs, params):\n", " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" - ] + ], + "id": "d98c2374a04c1424" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "0ac66d98", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Foo(d={'a': 1})" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" - ] + ], + "id": "1895949b223f331d" }, { - "cell_type": "code", - "execution_count": null, - "id": "090f1f0f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wrap_req(req, params):\n", " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" - ] + ], + "id": "e5f9b252b5404d6" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "26df5218", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "ok body=19\n" - ] - } - ], "source": [ "def g(test_val:int=0, item:HttpHeader=None, htmx:HtmxHeaders=None, api:ApiReturn=None): ...\n", "\n", @@ -1020,22 +876,14 @@ "r = client.post('/', data=dict(test_val='21', k='a', v='b'), headers={'HX-Request': '1'})\n", "test_eq(r.status_code, 200)\n", "print(r.text)" - ] + ], + "id": "212c21983334789e" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bf945ee8", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3'), 'state': }\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader, state): ...\n", "\n", @@ -1046,22 +894,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ] + ], + "id": "3aab2026066508b8" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "a3ded5ec", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3')}\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader): ...\n", "\n", @@ -1072,25 +912,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ] + ], + "id": "ad76da242022e3f4" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "0e7601fe", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "\"Bar(accept='application/json')\"" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "class Bar(BasicRepr):\n", " @classmethod\n", @@ -1105,38 +934,26 @@ "\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" - ] + ], + "id": "ead1df177cd0001" }, { - "cell_type": "markdown", - "id": "34066c89", "metadata": {}, - "source": [ - "**Missing Request Params**" - ] + "cell_type": "markdown", + "source": "**Missing Request Params**", + "id": "db783b911aea310d" }, { - "cell_type": "markdown", - "id": "06259883", "metadata": {}, - "source": [ - "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request." - ] + "cell_type": "markdown", + "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", + "id": "8774cb8b857934eb" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "c369a89c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'req': , 'this': , 'a': ''}\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str=''): ...\n", "\n", @@ -1147,30 +964,20 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ] + ], + "id": "624c7f99944813aa" }, { - "cell_type": "markdown", - "id": "35b68f4f", "metadata": {}, - "source": [ - "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`." - ] + "cell_type": "markdown", + "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", + "id": "83ffd4d5f69ee8e3" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b33b43a7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Missing required field: a\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str): ...\n", "\n", @@ -1181,14 +988,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ] + ], + "id": "454d528731449c" }, { - "cell_type": "code", - "execution_count": null, - "id": "7a661bfa", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def flat_xt(lst):\n", @@ -1199,85 +1006,83 @@ " if isinstance(item, (list,tuple)): result.extend(item)\n", " else: result.append(item)\n", " return tuple(result)" - ] + ], + "id": "693922c3e46c7754" }, { - "cell_type": "code", - "execution_count": null, - "id": "2ee5adf1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "x = ft('a',1)\n", "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" - ] + ], + "id": "581fbbf507982aae" }, { - "cell_type": "code", - "execution_count": null, - "id": "aacff5ac", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class Beforeware:\n", " \"Wrap a `before` function with `skip` path patterns that bypass it\"\n", " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" - ] + ], + "id": "6192cec3c0df1bb3" }, { - "cell_type": "code", - "execution_count": null, - "id": "78c3c357", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" - ] + ], + "id": "8ec98475a0a5c7d6" }, { - "cell_type": "code", - "execution_count": null, - "id": "c995850c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" - ] + ], + "id": "da268ae91d91d9bc" }, { - "cell_type": "markdown", - "id": "079a3215", "metadata": {}, - "source": [ - "## Websockets / SSE" - ] + "cell_type": "markdown", + "source": "## Websockets / SSE", + "id": "92d3135c4ad6710f" }, { - "cell_type": "code", - "execution_count": null, - "id": "ad0f0e87", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wrap_ws(ws, data, params):\n", " raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers`\n", " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" - ] + ], + "id": "5f2702274bb51006" }, { - "cell_type": "code", - "execution_count": null, - "id": "dcc15129", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _send_ws(ws, resp):\n", @@ -1306,14 +1111,14 @@ " if disconn: cls.on_disconnect = _disconnect\n", " cls.on_receive = _recv\n", " return cls" - ] + ], + "id": "9419cd0224cd98c2" }, { - "cell_type": "code", - "execution_count": null, - "id": "983bcfe2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def on_receive(msg:str): return f\"Message text was: {msg}\"\n", "c = _ws_endp(on_receive)\n", @@ -1322,14 +1127,14 @@ " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" - ] + ], + "id": "bc7c575a83684d5b" }, { - "cell_type": "code", - "execution_count": null, - "id": "bda2e425", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def on_receive(hx_trigger:str): return f\"trigger: {hx_trigger}\"\n", "cli = TestClient(Starlette(routes=[WebSocketRoute('/', _ws_endp(on_receive))]))\n", @@ -1337,27 +1142,27 @@ " ws.send_text(json.dumps({\"HEADERS\": {\"Hx-Trigger\": \"my-btn\"}}))\n", " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" - ] + ], + "id": "42a74b181d3b6a8f" }, { - "cell_type": "code", - "execution_count": null, - "id": "5b0e7677", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def EventStream(s):\n", " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" - ] + ], + "id": "741044b3e551225c" }, { - "cell_type": "code", - "execution_count": null, - "id": "0dd0a414", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def signal_shutdown():\n", @@ -1370,60 +1175,58 @@ " self.force_exit = True\n", " self._orig_handle_exit(*args, **kwargs)\n", " return event" - ] + ], + "id": "da2d506cff9cd847" }, { - "cell_type": "markdown", - "id": "496cd78f", "metadata": {}, - "source": [ - "## Routing and application" - ] + "cell_type": "markdown", + "source": "## Routing and application", + "id": "a4eeb45c2f82f3ff" }, { - "cell_type": "code", - "execution_count": null, - "id": "03f4c639", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def uri(_arg, **kwargs):\n", " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" - ] + ], + "id": "184283f3ee5d49ba" }, { - "cell_type": "code", - "execution_count": null, - "id": "bdb2ac14", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def decode_uri(s):\n", " \"Decode a URI created by `uri()` back into argument and keyword dict\"\n", " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" - ] + ], + "id": "ccfedf12c80ff96" }, { - "cell_type": "code", - "execution_count": null, - "id": "b80ce139", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "from starlette.convertors import StringConvertor" - ] + ], + "id": "fd09bc5e426f4a9c" }, { - "cell_type": "code", - "execution_count": null, - "id": "a27adc1e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "StringConvertor.regex = \"[^/]*\" # `+` replaced with `*`\n", @@ -1434,28 +1237,28 @@ " assert \"/\" not in value, \"May not contain path separators\"\n", " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" - ] + ], + "id": "7b7ad4249ea81211" }, { - "cell_type": "code", - "execution_count": null, - "id": "46614165", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", "def url_path_for(self:HTTPConnection, name: str, **path_params):\n", " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" - ] + ], + "id": "70c8ec8b0a8ab19f" }, { - "cell_type": "code", - "execution_count": null, - "id": "c1707d59", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "_verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href')\n", @@ -1482,25 +1285,25 @@ " \"Convert response to XML string with target URL resolution\"\n", " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" - ] + ], + "id": "3f97f1ed8c754dff" }, { - "cell_type": "code", - "execution_count": null, - "id": "f1e3ed2d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" - ] + ], + "id": "fb23ee8a4bdf6ffc" }, { - "cell_type": "code", - "execution_count": null, - "id": "a407dc0e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def flat_tuple(o):\n", @@ -1512,27 +1315,27 @@ " if isinstance(item, _iter_typs): result.extend(list(item))\n", " else: result.append(item)\n", " return tuple(result)" - ] + ], + "id": "f729e85becb6bbb6" }, { - "cell_type": "code", - "execution_count": null, - "id": "d36402e6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def noop_body(c, req):\n", " \"Default Body wrap function which just returns the content\"\n", " return c" - ] + ], + "id": "3e5a0b56fa0c4857" }, { - "cell_type": "code", - "execution_count": null, - "id": "7f49728d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def respond(req, heads, bdy):\n", @@ -1542,37 +1345,37 @@ " bw_args = (bdy, req) if len(params)>1 else (bdy,)\n", " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" - ] + ], + "id": "b8e014df4ebb00b3" }, { - "cell_type": "markdown", - "id": "cfae0a56", "metadata": {}, + "cell_type": "markdown", "source": [ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" - ] + ], + "id": "c42f6fb487aceee5" }, { - "cell_type": "code", - "execution_count": null, - "id": "b2007479", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def is_full_page(req, resp):\n", " \"Check if response should be rendered as full page or fragment\"\n", " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" - ] + ], + "id": "7ba6db1142901c4d" }, { - "cell_type": "code", - "execution_count": null, - "id": "358badec", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _part_resp(req, resp):\n", @@ -1590,14 +1393,14 @@ " resp = tuple(resp)\n", " if len(resp)==1: resp = resp[0]\n", " return resp,kw" - ] + ], + "id": "58f36aba629b2268" }, { - "cell_type": "code", - "execution_count": null, - "id": "da449a7b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _canonical(req):\n", @@ -1614,27 +1417,27 @@ " title = [] if any(getattr(o, 'tag', '')=='title' for o in heads) else [Title(req.app.title)]\n", " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" - ] + ], + "id": "a005b335ea8a4c04" }, { - "cell_type": "code", - "execution_count": null, - "id": "775fb66b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _is_ft_resp(resp):\n", " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" - ] + ], + "id": "73c48c2cb1072a7e" }, { - "cell_type": "code", - "execution_count": null, - "id": "968d9245", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _resp(req, resp, cls=empty, status_code=200):\n", @@ -1655,14 +1458,14 @@ " resp = str(resp)\n", " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" - ] + ], + "id": "7c02e80fed13238e" }, { - "cell_type": "code", - "execution_count": null, - "id": "eac44461", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class Redirect:\n", @@ -1671,28 +1474,28 @@ " def __response__(self, req):\n", " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" - ] + ], + "id": "896b46c4aa03192c" }, { - "cell_type": "code", - "execution_count": null, - "id": "1ba52822", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wrap_call(f, req, params):\n", " \"Wrap function call with request parameter injection\"\n", " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" - ] + ], + "id": "9b9774380fb5223c" }, { - "cell_type": "code", - "execution_count": null, - "id": "b0d1cbbf", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "# chkstyle: skip\n", @@ -1713,40 +1516,28 @@ " \"live\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js\",\n", " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js\"\n", "}" - ] + ], + "id": "5df474a1953c5134" }, { - "cell_type": "markdown", - "id": "3531273d", "metadata": {}, - "source": [ - "The FastHTML `exts` param supports the following:" - ] + "cell_type": "markdown", + "source": "The FastHTML `exts` param supports the following:", + "id": "d2f9aef7815d59bb" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "deffbaaa", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "morph head-support preload class-tools loading-states multi-swap path-deps remove-me debug ws chunked-transfer\n" - ] - } - ], - "source": [ - "print(' '.join(htmx_exts))" - ] + "source": "print(' '.join(htmx_exts))", + "id": "fe8fdb5cd2c94da9" }, { - "cell_type": "code", - "execution_count": null, - "id": "60cb52ea", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", @@ -1756,14 +1547,14 @@ "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" - ] + ], + "id": "ab5aae7c786f4db0" }, { - "cell_type": "code", - "execution_count": null, - "id": "8bd78eeb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def get_key(key=None, fname='.sesskey'):\n", @@ -1774,27 +1565,27 @@ " key = str(uuid.uuid4())\n", " fname.write_text(key)\n", " return key" - ] + ], + "id": "fd36cd44687c2273" }, { - "cell_type": "code", - "execution_count": null, - "id": "042666e2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _list(o):\n", " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" - ] + ], + "id": "97ae79dd8df934d9" }, { - "cell_type": "code", - "execution_count": null, - "id": "d276fc71", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap):\n", @@ -1805,14 +1596,14 @@ " res = await _handle(f, req, exc)\n", " return _resp(req, res, status_code=status_code)\n", " return _f" - ] + ], + "id": "440490e465c515b4" }, { - "cell_type": "code", - "execution_count": null, - "id": "bc323fd4", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def qp(p:str, **kw) -> str:\n", @@ -1825,63 +1616,57 @@ " p = re.sub(r'\\{([^:}]+)(:.+?)?}', _sub, p)\n", " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" - ] + ], + "id": "8c482e84ada42852" }, { - "cell_type": "markdown", - "id": "e51e2dc3", "metadata": {}, - "source": [ - "`qp` adds query parameters to route path strings" - ] + "cell_type": "markdown", + "source": "`qp` adds query parameters to route path strings", + "id": "2b1d80a716c0adef" }, { - "cell_type": "code", - "execution_count": null, - "id": "ff82dc78", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)" - ] + "execution_count": null, + "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", + "id": "54be93910e6e66ba" }, { - "cell_type": "code", - "execution_count": null, - "id": "c0836a8f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" - ] + ], + "id": "a7000a851f7ec0b1" }, { - "cell_type": "markdown", - "id": "4740272f", "metadata": {}, - "source": [ - "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly." - ] + "cell_type": "markdown", + "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", + "id": "9caeec84af6b57b9" }, { - "cell_type": "code", - "execution_count": null, - "id": "50ebb1ee", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "path = '/foo/{a}/{d}/{ab:int}'\n", "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" - ] + ], + "id": "37b1595d560ee414" }, { - "cell_type": "code", - "execution_count": null, - "id": "f86690c4", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def def_hdrs(htmx=True, htmx4=False, surreal=True):\n", @@ -1895,14 +1680,14 @@ " # TODO: Check if fhjsscr works with htmx4\n", " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" - ] + ], + "id": "6a347c42c3a0b6b8" }, { - "cell_type": "code", - "execution_count": null, - "id": "2c5285ae", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])\n", @@ -1928,14 +1713,14 @@ " }\n", " };\n", "\"\"\"))\n" - ] + ], + "id": "7de7d4d9ce75b85d" }, { - "cell_type": "code", - "execution_count": null, - "id": "17ced9a3", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class _LifespanCtx:\n", @@ -1963,14 +1748,14 @@ " for f in self.shutdown: await _handle(f)\n", "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" - ] + ], + "id": "852e2ec0c0969f52" }, { - "cell_type": "code", - "execution_count": null, - "id": "3327a1e9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class FastHTML(Starlette):\n", @@ -2010,22 +1795,20 @@ " super().__init__(debug, routes, middleware=middleware, exception_handlers=excs, lifespan=self.lifespan)\n", "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" - ] + ], + "id": "8e95f37034125b68" }, { - "cell_type": "markdown", - "id": "c7d4052c", "metadata": {}, - "source": [ - "### Routes" - ] + "cell_type": "markdown", + "source": "### Routes", + "id": "9fc92122a2fa70f2" }, { - "cell_type": "code", - "execution_count": null, - "id": "dce68049", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class HostRoute(Route):\n", @@ -2044,22 +1827,14 @@ " def __repr__(self) -> str:\n", " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" - ] + ], + "id": "fe44229b6931a496" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b8823cfe", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "all passed\n" - ] - } - ], "source": [ "app = FastHTML()\n", "app.router.routes = []\n", @@ -2072,22 +1847,14 @@ "test_eq(cli.get('/', headers={'host': 'localhost'}).text, 'main page')\n", "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" - ] + ], + "id": "3c04fe56abdc47fa" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "3da46acd", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "all passed\n" - ] - } - ], "source": [ "app.router.routes.append(HostRoute('/hello', lambda _: Response('api hello'), host='api.{parentdom}', methods=['GET'], name='api_hello'))\n", "app.router.routes.append(HostRoute('/hello', lambda _: Response('normal hello'), methods=['GET'], name='hello'))\n", @@ -2096,14 +1863,14 @@ "test_eq(cli.get('/hello', headers={'host': 'mysite.com'}).text, 'normal hello')\n", "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" - ] + ], + "id": "654aac80d680d4eb" }, { - "cell_type": "code", - "execution_count": null, - "id": "e0accf76", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2115,25 +1882,25 @@ " ((route.methods is None) or (set(r.methods) == set(route.methods))))]\n", " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" - ] + ], + "id": "18175f9ddfa24df4" }, { - "cell_type": "code", - "execution_count": null, - "id": "246bd8d1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" - ] + ], + "id": "c00f976494575cc" }, { - "cell_type": "code", - "execution_count": null, - "id": "26b147ba", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2163,14 +1930,14 @@ " if nr: resp = nr\n", " return _resp(req, resp, sig.return_annotation)\n", " return _f" - ] + ], + "id": "4837ef8ced64c5e0" }, { - "cell_type": "code", - "execution_count": null, - "id": "3818575c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2181,14 +1948,14 @@ " route.methods = ['ws']\n", " self.add_route(route)\n", " return func" - ] + ], + "id": "154ea30b2e45f577" }, { - "cell_type": "code", - "execution_count": null, - "id": "669e76eb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2196,28 +1963,28 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" - ] + ], + "id": "5655582e8b8129c6" }, { - "cell_type": "code", - "execution_count": null, - "id": "j6ete5u68fo", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#|export\n", "@patch\n", "def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None):\n", " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" - ] + ], + "id": "bb318a080450cf8c" }, { - "cell_type": "code", - "execution_count": null, - "id": "919618c3", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _mk_locfunc(f, p, app=None):\n", @@ -2232,61 +1999,50 @@ " def __str__(self): return p\n", "\n", " return _lf()" - ] + ], + "id": "a294e823478edca7" }, { - "cell_type": "code", - "execution_count": null, - "id": "d2ecc738", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def nested_name(f):\n", " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" - ] + ], + "id": "ee7941c6d76afd9e" }, { - "cell_type": "code", - "execution_count": null, - "id": "c0f13ece", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def f():\n", " def g(): ...\n", " return g" - ] + ], + "id": "67a112ba5ba9ed0d" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b218f738", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'f_g'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "func = f()\n", "nested_name(func)" - ] + ], + "id": "e04010a30af82a09" }, { - "cell_type": "code", - "execution_count": null, - "id": "daafe4fc", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2299,14 +2055,14 @@ " handler = getattr(cls, meth, None)\n", " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" - ] + ], + "id": "ebb18855aabae4a3" }, { - "cell_type": "code", - "execution_count": null, - "id": "3710e48b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _route_pn(func, path, name):\n", @@ -2332,14 +2088,14 @@ " lf = _mk_locfunc(func, p, app=self)\n", " lf.__routename__ = n\n", " return lf" - ] + ], + "id": "ce6f387e5f02d9db" }, { - "cell_type": "code", - "execution_count": null, - "id": "f5cb2c2b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2350,71 +2106,47 @@ " return f(path) if callable(path) else f\n", "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" - ] + ], + "id": "112b50b015b12164" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "e6ee3a86", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/foo?a=bar&b=1&b=2'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app = FastHTML()\n", "@app.get\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ] + ], + "id": "dff8e8a4a56cf236" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "9b9f1f03", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/foo/bar?b=1&b=2'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@app.get('/foo/{a}')\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ] + ], + "id": "37009106fa07a942" }, { - "cell_type": "markdown", - "id": "4ab96dc2", "metadata": {}, - "source": [ - "### Utils" - ] + "cell_type": "markdown", + "source": "### Utils", + "id": "16d546c2d4b56c7f" }, { - "cell_type": "code", - "execution_count": null, - "id": "35c35a96", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2422,14 +2154,14 @@ " \"Set the lifespan context manager for the FastHTML app\"\n", " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" - ] + ], + "id": "7dadb462ba00d11c" }, { - "cell_type": "code", - "execution_count": null, - "id": "3a348474", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def serve(\n", @@ -2456,22 +2188,20 @@ " link = f'http://{\"localhost\" if host==\"0.0.0.0\" else host}:{port}'\n", " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" - ] + ], + "id": "c36b7185c1679ba5" }, { - "cell_type": "markdown", - "id": "428b0e9c", "metadata": {}, - "source": [ - "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them." - ] + "cell_type": "markdown", + "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", + "id": "b9e62008c275f97b" }, { - "cell_type": "code", - "execution_count": null, - "id": "91b08e9a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _spawn(n):\n", @@ -2479,22 +2209,20 @@ " ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)]\n", " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" - ] + ], + "id": "e35a3b4cdd465a37" }, { - "cell_type": "markdown", - "id": "1129768b", "metadata": {}, - "source": [ - "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first." - ] + "cell_type": "markdown", + "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", + "id": "24c42a8e514a9e5f" }, { - "cell_type": "code", - "execution_count": null, - "id": "c5220a0e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wait_disconnect(req):\n", @@ -2510,22 +2238,20 @@ " finally:\n", " watch.cancel()\n", " if not work.done(): work.cancel()" - ] + ], + "id": "42ca8bdf84326b3b" }, { - "cell_type": "markdown", - "id": "8c6a8cb8", "metadata": {}, - "source": [ - "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled." - ] + "cell_type": "markdown", + "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", + "id": "d6f88d179121010f" }, { - "cell_type": "code", - "execution_count": null, - "id": "c5721d76", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def cancel_on_disconnect(\n", @@ -2540,14 +2266,14 @@ " c = until_disconnect(__req, f(**kw))\n", " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" - ] + ], + "id": "27f2e4665ebf5bbc" }, { - "cell_type": "code", - "execution_count": null, - "id": "8121968a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class Client:\n", @@ -2561,92 +2287,55 @@ " with from_thread.start_blocking_portal() as portal: return portal.call(_request)\n", "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" - ] + ], + "id": "8db5fe95c7d00bec" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b163c933", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'test'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app = FastHTML(routes=[Route('/', lambda _: Response('test'))])\n", "cli = Client(app)\n", "\n", "cli.get('/').text" - ] + ], + "id": "ad12879dd1f19e35" }, { - "cell_type": "markdown", - "id": "efc8e6db", "metadata": {}, - "source": [ - "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable." - ] + "cell_type": "markdown", + "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", + "id": "a67770455c7eb2b8" }, { - "cell_type": "markdown", - "id": "2d2633dd", "metadata": {}, - "source": [ - "## FastHTML Tests" - ] + "cell_type": "markdown", + "source": "## FastHTML Tests", + "id": "f8a6739566557720" }, { - "cell_type": "code", - "execution_count": null, - "id": "9abc3781", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "def get_cli(app): return app,TestClient(app),app.route" - ] + "execution_count": null, + "source": "def get_cli(app): return app,TestClient(app),app.route", + "id": "563e9f037bfba8a1" }, { - "cell_type": "code", - "execution_count": null, - "id": "645d8d95", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" - ] + "execution_count": null, + "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", + "id": "541824a436755f55" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "421262a8", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[HostRoute(path='/foo', name='foo', methods=['GET', 'HEAD'], host=None)]\n" - ] - }, - { - "data": { - "text/plain": [ - "'/foo?param=value'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app,cli,rt = get_cli(FastHTML(title=\"My Custom Title\"))\n", "@app.get\n", @@ -2658,22 +2347,14 @@ "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ] + ], + "id": "c639800adf059ebf" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "8836b62c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[HostRoute(path='/foo', name='foo', methods=['GET', 'HEAD', 'POST'], host='test.foo'), HostRoute(path='/foo', name='foo', methods=['GET', 'HEAD'], host=None)]\n" - ] - } - ], "source": [ "@rt(host='test.foo')\n", "def foo(): return Div(\"Goodbye World\")\n", @@ -2682,38 +2363,27 @@ "\n", "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" - ] + ], + "id": "84739e25115b6885" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "377012de", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/foo?param=value'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "response = cli.get('/foo')\n", "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ] + ], + "id": "a9ab959322b3c533" }, { - "cell_type": "code", - "execution_count": null, - "id": "2ebd6270", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -2722,26 +2392,26 @@ "\n", "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" - ] + ], + "id": "4b7beef96901eff5" }, { - "cell_type": "code", - "execution_count": null, - "id": "274653c2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def test_r(cli, path, exp, meth='get', hx=False, **kwargs):\n", " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" - ] + ], + "id": "772f8f233bfdf3a0" }, { - "cell_type": "code", - "execution_count": null, - "id": "b8a77cde", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@dataclass\n", "class Card: id:str; title:str\n", @@ -2756,14 +2426,14 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ] + ], + "id": "a3cba3e1f15e799a" }, { - "cell_type": "code", - "execution_count": null, - "id": "ac7b527d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt\n", "class Things:\n", @@ -2771,95 +2441,54 @@ "\n", "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" - ] + ], + "id": "97b51681c70ff08" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "8a6ed879", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Hi there'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt(\"/hi\")\n", "def get(): return 'Hi there'\n", "\n", "r = cli.get('/hi')\n", "r.text" - ] + ], + "id": "b0d1876823809add" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "a8e723fc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Postal'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt(\"/hi\")\n", "def post(): return 'Postal'\n", "\n", "cli.post('/hi').text" - ] + ], + "id": "afc2ab1803fa418a" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "274666db", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'testserver'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@app.get(\"/hostie\")\n", "def show_host(req): return req.headers['host']\n", "\n", "cli.get('/hostie').text" - ] + ], + "id": "a475f9d4844d8a3f" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "72428702", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Message text was: Hi! with session bar, from client: Address(host='testclient', port=50000)\n" - ] - } - ], "source": [ "@app.get(\"/setsess\")\n", "def set_sess(session):\n", @@ -2875,215 +2504,100 @@ " data = ws.receive_text()\n", "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" - ] + ], + "id": "3177015fbac381ec" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "36292bf3", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'a yoyo'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt\n", "def yoyo(): return 'a yoyo'\n", "\n", "cli.post('/yoyo').text" - ] + ], + "id": "6b0d7ef1d4bd445d" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "0d813cd0", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "
Text.
\n", - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], "source": [ "@app.get\n", "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" - ] + ], + "id": "7c411a85a21ad08e" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "52dde0da", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - " \n", - "
Text.
\n", - " \n", - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], "source": [ "@app.get\n", "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" - ] + ], + "id": "8c38b939291ab644" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "d84d98f1", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "
Text.
\n", - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], "source": [ "@app.get\n", "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" - ] + ], + "id": "a77a1f47c56e36d9" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "64343367", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Good day to you, Alexis!'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt('/user/{nm}', name='gday')\n", "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" - ] + ], + "id": "3c20651b84de0f59" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "29a96715", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "
Text.
\n", - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], "source": [ "@app.get\n", "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" - ] + ], + "id": "2c788900a2ccdb73" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "54266599", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'http://testserver/user/Alexis; http://testserver/hostie'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt('/link')\n", "def get(req): return f\"{req.url_for('gday', nm='Alexis')}; {req.url_for('show_host')}\"\n", "\n", "cli.get('/link').text" - ] + ], + "id": "f657a1f73d5dfd2f" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "162b811e", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Background task completed!\n" - ] - } - ], "source": [ "@app.get(\"/background\")\n", "async def background_task(request):\n", @@ -3093,24 +2607,22 @@ " return P(\"Task started\"), BackgroundTask(long_running_task)\n", "\n", "response = cli.get(\"/background\")" - ] + ], + "id": "8635c1055169bedf" }, { - "cell_type": "code", - "execution_count": null, - "id": "db0281cf", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')" - ] + "execution_count": null, + "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", + "id": "b01c37683cd5749d" }, { - "cell_type": "code", - "execution_count": null, - "id": "b827960a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "hxhdr = {'headers':{'hx-request':\"1\"}}\n", "\n", @@ -3133,62 +2645,62 @@ "\n", "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" - ] + ], + "id": "985d18d4a0b94a29" }, { - "cell_type": "code", - "execution_count": null, - "id": "1c2311f8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# declaration-time warnings\n", "def _noanno(nope): return nope\n", "def _bare(names:list): return str(names)\n", "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" - ] + ], + "id": "2ce18b792ac77487" }, { - "cell_type": "code", - "execution_count": null, - "id": "381a1b68", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# runtime warnings\n", "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" - ] + ], + "id": "26ea68a93689fd0b" }, { - "cell_type": "code", - "execution_count": null, - "id": "80238019", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" - ] + ], + "id": "e94e2e251db62443" }, { - "cell_type": "code", - "execution_count": null, - "id": "1b4b5717", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" - ] + ], + "id": "32fa349b79d75e70" }, { - "cell_type": "code", - "execution_count": null, - "id": "dd017867", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt(\"/models/{nm}\")\n", "def get(nm:ModelName): return nm\n", @@ -3207,14 +2719,14 @@ "\n", "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" - ] + ], + "id": "a4b214dffb848f61" }, { - "cell_type": "code", - "execution_count": null, - "id": "52d9a3f2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/html/1', headers={'hx-request':\"1\"})\n", "assert '

Next is 2.

' in r.text\n", @@ -3228,25 +2740,14 @@ "assert cli.get('/idxl/?idx=1&idx=g').status_code == 404\n", "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" - ] + ], + "id": "5496149d1a02c20d" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bb8154cc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'got sub/a.b'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app = FastHTML()\n", "rt = app.route\n", @@ -3256,45 +2757,30 @@ "@app.route(r'/static/{path:path}')\n", "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" - ] + ], + "id": "855956c3bfb4adee" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "65a29643", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'also got sub/a.b,1'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cli.get('/static/sub/a.b?a=1').text" - ] + "source": "cli.get('/static/sub/a.b?a=1').text", + "id": "97b1292db472196b" }, { - "cell_type": "code", - "execution_count": null, - "id": "6bc9564c", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "app.chk = 'foo'" - ] + "execution_count": null, + "source": "app.chk = 'foo'", + "id": "ea361055b7188aff" }, { - "cell_type": "code", - "execution_count": null, - "id": "b8be4ef3", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@app.get(\"/booly/\")\n", "def _(coming:bool=True): return 'Coming' if coming else 'Not coming'\n", @@ -3322,14 +2808,14 @@ "\n", "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" - ] + ], + "id": "7f15062539128c48" }, { - "cell_type": "code", - "execution_count": null, - "id": "5f045cf9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_r(cli, '/booly/?coming=true', 'Coming')\n", "test_r(cli, '/booly/?coming=no', 'Not coming')\n", @@ -3339,59 +2825,59 @@ "test_r(cli, '/hxtest' , '1', headers={'HX-Request':'1'})\n", "test_r(cli, '/hxtest2', '1', headers={'HX-Request':'1'})\n", "test_r(cli, '/app' , 'foo')" - ] + ], + "id": "944594c4209fdc7c" }, { - "cell_type": "code", - "execution_count": null, - "id": "52f9f934", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app2', **hxhdr)\n", "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" - ] + ], + "id": "b4689d6ff7d4123a" }, { - "cell_type": "code", - "execution_count": null, - "id": "26d316b6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" - ] + ], + "id": "295ae52452b974b6" }, { - "cell_type": "code", - "execution_count": null, - "id": "704aeba6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" - ] + ], + "id": "5dfa9fdf9be7c0ab" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fa127d5", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" - ] + ], + "id": "c25ee2b4ae9af9db" }, { - "cell_type": "code", - "execution_count": null, - "id": "356db6c0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt\n", "def meta(): return ((Title('hi'), H1('hi')), (Meta(property='image'), Meta(property='site_name')))\n", @@ -3399,14 +2885,14 @@ "t = cli.post('/meta').text\n", "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" - ] + ], + "id": "f8c5a79a9e268cad" }, { - "cell_type": "code", - "execution_count": null, - "id": "0aeaac36", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# Testing POST with Content-Type: application/json\n", "@app.post(\"/bodytext\")\n", @@ -3529,39 +3013,22 @@ "\n", "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" - ] + ], + "id": "1c271ec1f072e066" }, { - "cell_type": "code", - "execution_count": null, - "id": "4034ee37", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]" - ] + "execution_count": null, + "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", + "id": "93a69784d0a2b6b3" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "04881594", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "200\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "content1,content2\n" - ] - } - ], "source": [ "@rt(\"/uploads\")\n", "async def post(files:list[UploadFile]): return ','.join([(await file.read()).decode() for file in files])\n", @@ -3569,44 +3036,26 @@ "res = cli.post('/uploads', files=files)\n", "print(res.status_code)\n", "print(res.text)" - ] + ], + "id": "8f88c027dbebba62" }, { - "cell_type": "code", - "execution_count": null, - "id": "28a99667", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "res = cli.post('/uploads', files=[files[0]])\n", "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" - ] + ], + "id": "a1705546441529ca" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5bce33f0", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set to 2026-06-29 21:09:52.332526\n" - ] - }, - { - "data": { - "text/plain": [ - "'Session time: 2026-06-29 21:09:52.332526'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt(\"/setsess\")\n", "def get(sess, foo:str=''):\n", @@ -3621,14 +3070,14 @@ "time.sleep(0.01)\n", "\n", "cli.get('/getsess').text" - ] + ], + "id": "ad0e269042b27e7e" }, { - "cell_type": "code", - "execution_count": null, - "id": "249fce31", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt(\"/sess-first\")\n", "def post(sess, name: str):\n", @@ -3641,69 +3090,59 @@ "def get(sess): return sess['name']\n", "\n", "test_eq(cli.get('/getsess-all').text, '2')" - ] + ], + "id": "f5ca9e1ef115a62a" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "4d17ab9c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "# Release notes\n" - ] - } - ], "source": [ "@rt(\"/upload\")\n", "async def post(uf:UploadFile): return (await uf.read()).decode()\n", "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" - ] + ], + "id": "409b7720eb224b52" }, { - "cell_type": "code", - "execution_count": null, - "id": "102c17ab", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt(\"/form-submit/{list_id}\")\n", "def options(list_id: str):\n", " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" - ] + ], + "id": "f343fe095bb6667" }, { - "cell_type": "code", - "execution_count": null, - "id": "85bf2984", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" - ] + ], + "id": "c079a9480076de20" }, { - "cell_type": "code", - "execution_count": null, - "id": "e4fc13b0", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "from fasthtml.authmw import user_pwd_auth" - ] + "execution_count": null, + "source": "from fasthtml.authmw import user_pwd_auth", + "id": "c3a0b4e98373614e" }, { - "cell_type": "code", - "execution_count": null, - "id": "1f11eab1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def _not_found(req, exc): return Div('nope')\n", "\n", @@ -3712,14 +3151,14 @@ "txt = cli.get('/').text\n", "assert '
nope
' in txt\n", "assert '' in txt" - ] + ], + "id": "2f8b30794712e0c2" }, { - "cell_type": "code", - "execution_count": null, - "id": "063b7f43", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3728,14 +3167,14 @@ "\n", "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" - ] + ], + "id": "90ba5b8403a3879d" }, { - "cell_type": "code", - "execution_count": null, - "id": "efd6fc6c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3745,14 +3184,14 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ] + ], + "id": "cd1418adab7bc3b1" }, { - "cell_type": "code", - "execution_count": null, - "id": "32520197", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3762,32 +3201,28 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ] + ], + "id": "3d6b883e14f63a74" }, { - "cell_type": "markdown", - "id": "967001f3", "metadata": {}, - "source": [ - "Check that afterware like toasts are called correctly." - ] + "cell_type": "markdown", + "source": "Check that afterware like toasts are called correctly.", + "id": "504abfeb5c1f9a9c" }, { - "cell_type": "code", - "execution_count": null, - "id": "09a5fb96", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "from fasthtml.toaster import *" - ] + "execution_count": null, + "source": "from fasthtml.toaster import *", + "id": "bf8618699f50aed1" }, { - "cell_type": "code", - "execution_count": null, - "id": "c7500555", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3822,32 +3257,28 @@ "\n", "r = cli.get('/')\n", "assert tm in r.text" - ] + ], + "id": "8d549d9a5c93d99d" }, { - "cell_type": "markdown", - "id": "bd0e4c06", "metadata": {}, - "source": [ - "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762)." - ] + "cell_type": "markdown", + "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", + "id": "9fab408e2e6e686" }, { - "cell_type": "code", - "execution_count": null, - "id": "e2417e5f", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "assert 'id=\"fh-toast-container\"' in r.text" - ] + "execution_count": null, + "source": "assert 'id=\"fh-toast-container\"' in r.text", + "id": "4bd5e983e3d3af53" }, { - "cell_type": "code", - "execution_count": null, - "id": "f95e80d7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML(on_startup=[lambda: setattr(app, '_started', True)],\n", " on_shutdown=[lambda: setattr(app, '_stopped', True)]))\n", @@ -3857,14 +3288,14 @@ "\n", "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" - ] + ], + "id": "f5bd3ba8f7e070c1" }, { - "cell_type": "code", - "execution_count": null, - "id": "190ce9d0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3875,22 +3306,20 @@ "def index(): return app._evt\n", "\n", "with cli: test_eq(cli.get('/').text, 'fired')" - ] + ], + "id": "e1e0a2981bdd9bed" }, { - "cell_type": "markdown", - "id": "b9fdbdd8", "metadata": {}, - "source": [ - "A route-level `before` can set session data, short-circuit with a response, or raise to block access:" - ] + "cell_type": "markdown", + "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", + "id": "e186cdcbef7aa053" }, { - "cell_type": "code", - "execution_count": null, - "id": "f94ea388", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def check_user(sess, user:str=''):\n", " if user == 'admin':\n", @@ -3905,22 +3334,20 @@ "test_eq(cli.get('/dashboard?user=admin').text, 'bar')\n", "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" - ] + ], + "id": "375bfb6e0574c3b1" }, { - "cell_type": "markdown", - "id": "35615416", "metadata": {}, - "source": [ - "Multiple `before` functions run in order, each receiving the injected request params:" - ] + "cell_type": "markdown", + "source": "Multiple `before` functions run in order, each receiving the injected request params:", + "id": "59fd36c5878e8631" }, { - "cell_type": "code", - "execution_count": null, - "id": "6b751acb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def set_a(sess): sess['a'] = 1\n", "def set_b(sess): sess['b'] = 2\n", @@ -3929,22 +3356,20 @@ "def get(sess): return f\"{sess.get('a')},{sess.get('b')}\"\n", "\n", "test_eq(cli.get('/multi').text, '1,2')" - ] + ], + "id": "1b6335c5da6b5da4" }, { - "cell_type": "markdown", - "id": "49cc8791", "metadata": {}, - "source": [ - "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):" - ] + "cell_type": "markdown", + "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", + "id": "b8218676a7553fae" }, { - "cell_type": "code", - "execution_count": null, - "id": "83188ab0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "calls = []\n", "def first(sess): \n", @@ -3957,22 +3382,20 @@ "\n", "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" - ] + ], + "id": "ff2355f5ac88a72d" }, { - "cell_type": "markdown", - "id": "a43466d4", "metadata": {}, - "source": [ - "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:" - ] + "cell_type": "markdown", + "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", + "id": "80260448c4476ee0" }, { - "cell_type": "code", - "execution_count": null, - "id": "4457e14e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3981,14 +3404,14 @@ "async def cq(q:str): return f'got {q}'\n", "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" - ] + ], + "id": "b2148b69758de6f" }, { - "cell_type": "code", - "execution_count": null, - "id": "59ed14fc", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt('/sleepy')\n", "@cancel_on_disconnect(timeout=0.01)\n", @@ -3997,22 +3420,20 @@ " return 'nope'\n", "\n", "test_fail(lambda: cli.get('/sleepy'))" - ] + ], + "id": "ac7273a868548b84" }, { - "cell_type": "markdown", - "id": "c03768de", "metadata": {}, - "source": [ - "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:" - ] + "cell_type": "markdown", + "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", + "id": "5819b50f4728bc55" }, { - "cell_type": "code", - "execution_count": null, - "id": "8e78bea6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "async def _disc(): return {'type': 'http.disconnect'}\n", "cancelled = []\n", @@ -4030,22 +3451,20 @@ "\n", "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" - ] + ], + "id": "6dd453e187a01b13" }, { - "cell_type": "markdown", - "id": "6a014add", "metadata": {}, - "source": [ - "## APIRouter" - ] + "cell_type": "markdown", + "source": "## APIRouter", + "id": "cb43430858dcac10" }, { - "cell_type": "code", - "execution_count": null, - "id": "d5223a9a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class RouteFuncs:\n", @@ -4057,14 +3476,14 @@ " try: return self._funcs[name]\n", " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" - ] + ], + "id": "6d212239ee4b3ebb" }, { - "cell_type": "code", - "execution_count": null, - "id": "0f08cb5e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class APIRouter:\n", @@ -4105,24 +3524,22 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" - ] + ], + "id": "c069560553ea4e31" }, { - "cell_type": "code", - "execution_count": null, - "id": "22e17ec8", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "ar = APIRouter()" - ] + "execution_count": null, + "source": "ar = APIRouter()", + "id": "7053f84ef1ee93f6" }, { - "cell_type": "code", - "execution_count": null, - "id": "61030ee4", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@ar(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -4145,25 +3562,25 @@ "\n", "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ] + ], + "id": "c0ff4dc1dfe83148" }, { - "cell_type": "code", - "execution_count": null, - "id": "cd413b0d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" - ] + ], + "id": "d78e389d3610d965" }, { - "cell_type": "code", - "execution_count": null, - "id": "8c265ff8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "assert str(yoyo) == '/yoyo'\n", "# ensure route functions are properly discoverable on `APIRouter` and `APIRouter.rt_funcs`\n", @@ -4174,14 +3591,14 @@ "with ExceptionExpected(): ar.rt_funcs.blah()\n", "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" - ] + ], + "id": "c8ebe7cfdc63718" }, { - "cell_type": "code", - "execution_count": null, - "id": "8f7c5710", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_eq(cli.get('/hi').text, 'Hi there')\n", "test_eq(cli.post('/hi').text, 'Postal')\n", @@ -4195,37 +3612,35 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ] + ], + "id": "6a60b59b118846a6" }, { - "cell_type": "code", - "execution_count": null, - "id": "1236de78", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "with cli.websocket_connect('/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ] + ], + "id": "b8e12162ea3e310c" }, { - "cell_type": "code", - "execution_count": null, - "id": "02a4e649", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "ar2 = APIRouter(\"/products\")" - ] + "execution_count": null, + "source": "ar2 = APIRouter(\"/products\")", + "id": "34dc5eb7b3152b1c" }, { - "cell_type": "code", - "execution_count": null, - "id": "151b9e3c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@ar2(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -4246,25 +3661,25 @@ "\n", "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ] + ], + "id": "5ea40de23ef540fd" }, { - "cell_type": "code", - "execution_count": null, - "id": "77ce8548", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" - ] + ], + "id": "50efe1782d71c63d" }, { - "cell_type": "code", - "execution_count": null, - "id": "f1fc8425", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "assert str(yoyo) == '/products/yoyo'\n", "assert ar2.prefix == '/products'\n", @@ -4274,14 +3689,14 @@ "with ExceptionExpected(): ar2.blah()\n", "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" - ] + ], + "id": "5e76acbce9b744e9" }, { - "cell_type": "code", - "execution_count": null, - "id": "f265860d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_eq(cli.get('/products/hi').text, 'Hi there')\n", "test_eq(cli.post('/products/hi').text, 'Postal')\n", @@ -4293,38 +3708,38 @@ "\n", "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" - ] + ], + "id": "74d0e896f59faa34" }, { - "cell_type": "code", - "execution_count": null, - "id": "e38d99cf", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "with cli.websocket_connect('/products/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ] + ], + "id": "3f575fb7222c54cd" }, { - "cell_type": "code", - "execution_count": null, - "id": "259a0f53", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" - ] + ], + "id": "6d6695412ac8dccd" }, { - "cell_type": "code", - "execution_count": null, - "id": "f61d110c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@ar.get\n", "def hi2(): return 'Hi there'\n", @@ -4339,32 +3754,28 @@ "def _(): return 'Hi there'\n", "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" - ] + ], + "id": "2fd1f4a196f51927" }, { - "cell_type": "markdown", - "id": "dfa6f859", "metadata": {}, - "source": [ - "## Extras" - ] + "cell_type": "markdown", + "source": "## Extras", + "id": "d14d79e176792cd8" }, { - "cell_type": "code", - "execution_count": null, - "id": "7438435e", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" - ] + "execution_count": null, + "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", + "id": "710d59fe07e76a4e" }, { - "cell_type": "code", - "execution_count": null, - "id": "e518de83", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def cookie(key: str, value=\"\", max_age=None, expires=None, path=\"/\", domain=None, secure=False, httponly=False, samesite=\"lax\",):\n", @@ -4382,48 +3793,14 @@ " cookie[key][\"samesite\"] = samesite\n", " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "919e600a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - " \n", - " FastHTML page\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "data": { - "text/plain": [ - "'Cookie was set at time 21:09:55.336114'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } ], + "id": "4b8cabb822ba5c06" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "@rt(\"/setcookie\")\n", "def get(req): return cookie('now', datetime.now())\n", @@ -4434,28 +3811,28 @@ "print(cli.get('/setcookie').text)\n", "time.sleep(0.01)\n", "cli.get('/getcookie').text" - ] + ], + "id": "9020523bd7e93f" }, { - "cell_type": "code", - "execution_count": null, - "id": "8816f277", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def reg_re_param(m, s):\n", " \"Register a Starlette URL convertor named `m` matching regex `s`\"\n", " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" - ] + ], + "id": "5cdac562bf659976" }, { - "cell_type": "code", - "execution_count": null, - "id": "e9535978", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "# Starlette doesn't have the '?', so it chomps the whole remaining URL\n", @@ -4468,14 +3845,14 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`\"\n", " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" - ] + ], + "id": "f39ce8a9974eb311" }, { - "cell_type": "code", - "execution_count": null, - "id": "9a70842e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "reg_re_param(\"imgext\", \"ico|gif|jpg|jpeg|webm|pdf\")\n", "\n", @@ -4483,25 +3860,25 @@ "def get(fn:str, path:str, ext:str): return f\"Getting {fn}.{ext} from /{path}\"\n", "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" - ] + ], + "id": "652c879213bfb742" }, { - "cell_type": "code", - "execution_count": null, - "id": "8bb51383", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" - ] + ], + "id": "14ac1dcb187e9ded" }, { - "cell_type": "code", - "execution_count": null, - "id": "b31de65a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -4509,25 +3886,25 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')\"\n", " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" - ] + ], + "id": "f3b305241d618c09" }, { - "cell_type": "code", - "execution_count": null, - "id": "d4ff5919", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" - ] + ], + "id": "a71a0eb39efe8d5c" }, { - "cell_type": "code", - "execution_count": null, - "id": "f63b7a03", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class StaticNoCache(StaticFiles):\n", @@ -4536,14 +3913,14 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" - ] + ], + "id": "dfc415e286f4b8" }, { - "cell_type": "code", - "execution_count": null, - "id": "74801f94", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class StaticImmutable(StaticFiles):\n", @@ -4552,22 +3929,20 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" - ] + ], + "id": "23fae82950b80317" }, { - "cell_type": "markdown", - "id": "1c0531b8", "metadata": {}, - "source": [ - "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide." - ] + "cell_type": "markdown", + "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", + "id": "58f59f41ea0160e0" }, { - "cell_type": "code", - "execution_count": null, - "id": "27910e71", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "statd = Path(tempfile.mkdtemp())\n", "(statd/'app.css').write_text('body{}')\n", @@ -4575,14 +3950,14 @@ "stat_cli = TestClient(stat_app)\n", "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" - ] + ], + "id": "d107a4932dcdd8a" }, { - "cell_type": "code", - "execution_count": null, - "id": "e38fd6c7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@flexicache(mtime_policy(arg=0))\n", @@ -4594,22 +3969,20 @@ " p = Path(root)/u.path.lstrip('/')\n", " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" - ] + ], + "id": "17e13907ee30fc50" }, { - "cell_type": "markdown", - "id": "bc5b7561", "metadata": {}, - "source": [ - "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload." - ] + "cell_type": "markdown", + "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", + "id": "b72b1bfca5946f5b" }, { - "cell_type": "code", - "execution_count": null, - "id": "28e4a133", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "tmp = Path(tempfile.mkdtemp())\n", "(tmp/'a.css').write_text('body{}')\n", @@ -4622,26 +3995,26 @@ "(tmp/'a.css').write_text('body{color:red}')\n", "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" - ] + ], + "id": "8215a537819de8bd" }, { - "cell_type": "code", - "execution_count": null, - "id": "7189daf8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "from functools import wraps\n", "from inspect import signature, isawaitable" - ] + ], + "id": "1a6cbfc05998c35d" }, { - "cell_type": "code", - "execution_count": null, - "id": "7eed23b7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty):\n", @@ -4653,14 +4026,14 @@ " f.__signature__ = sig.replace(parameters=new_params)\n", " f.__annotations__[name] = typ\n", " return f" - ] + ], + "id": "66cc946f0f8a8894" }, { - "cell_type": "code", - "execution_count": null, - "id": "9e5a9e88", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class into:\n", @@ -4683,30 +4056,26 @@ " anno = p.annotation if p.annotation is not Parameter.empty else NoneType\n", " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" - ] + ], + "id": "ffee8e07c9b7665d" }, { - "cell_type": "markdown", - "id": "eba9a334", "metadata": {}, - "source": [ - "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically." - ] + "cell_type": "markdown", + "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", + "id": "1d7a5679528c39d" }, { - "cell_type": "markdown", - "id": "163acb67", "metadata": {}, - "source": [ - "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params." - ] + "cell_type": "markdown", + "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", + "id": "fa58fb4fd02c2db0" }, { - "cell_type": "code", - "execution_count": null, - "id": "50a9abbd", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -4715,54 +4084,50 @@ "@rt\n", "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" - ] + ], + "id": "3e1fe925afb7969" }, { - "cell_type": "markdown", - "id": "9aa0ac77", "metadata": {}, - "source": [ - "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\"." - ] + "cell_type": "markdown", + "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", + "id": "6d3457d3d02d70e" }, { - "cell_type": "code", - "execution_count": null, - "id": "3a08f786", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/hello')\n", "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" - ] + ], + "id": "2e12f2654d9f9548" }, { - "cell_type": "markdown", - "id": "5a172800", "metadata": {}, - "source": [ - "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`." - ] + "cell_type": "markdown", + "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", + "id": "d16aabdb9df9fc0d" }, { - "cell_type": "code", - "execution_count": null, - "id": "0fcfbf16", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/hello?name=Jeremy&title=Greetings')\n", "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" - ] + ], + "id": "f813fe19102b7fc7" }, { - "cell_type": "code", - "execution_count": null, - "id": "1960d7ff", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class MiddlewareBase:\n", @@ -4771,14 +4136,14 @@ " if scope[\"type\"] in (\"http\", \"websocket\"): return HTTPConnection(scope)\n", " await self._app(scope, receive, send)\n", " " - ] + ], + "id": "621de4f72f855409" }, { - "cell_type": "code", - "execution_count": null, - "id": "83a20f93", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class FtResponse:\n", @@ -4794,14 +4159,14 @@ " if not tasks: tasks = self.background\n", " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" - ] + ], + "id": "a19aeb513a26811d" }, { - "cell_type": "code", - "execution_count": null, - "id": "a9e66b7d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt('/ftr')\n", "def get():\n", @@ -4814,37 +4179,20 @@ "test_eq(r.headers['location'], '/foo/1')\n", "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" - ] + ], + "id": "97242b82a893fb30" }, { - "cell_type": "markdown", - "id": "94705ca7", "metadata": {}, - "source": [ - "Test on a single background task:" - ] + "cell_type": "markdown", + "source": "Test on a single background task:", + "id": "cbc9c706e4bf24c6" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "8cf7433e", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Starting slow task\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Finished slow task\n" - ] - } - ], "source": [ "def my_slow_task():\n", " print('Starting slow task') \n", @@ -4858,79 +4206,34 @@ "\n", "test_eq(r.status_code, 200)\n", "\n" - ] + ], + "id": "dcd54920e1965347" }, { - "cell_type": "markdown", - "id": "47c26feb", "metadata": {}, - "source": [ - "Test multiple background tasks:" - ] + "cell_type": "markdown", + "source": "Test multiple background tasks:", + "id": "9400f23cc2ac6adc" }, { - "cell_type": "code", - "execution_count": null, - "id": "76d25483", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def increment(amount):\n", " amount = amount/1000\n", " print(f'Sleeping for {amount}s') \n", " time.sleep(amount)\n", " print(f'Slept for {amount}s') " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bd7cf045", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.001s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.001s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.002s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.002s\n" - ] - } ], + "id": "20ea3030ace925f8" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "@rt\n", "def backgrounds():\n", @@ -4940,57 +4243,14 @@ "\n", "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5310fea9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.001s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.001s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.002s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.002s\n" - ] - } ], + "id": "eab10bb45ede6f25" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "@rt\n", "def backgrounds2():\n", @@ -4999,67 +4259,14 @@ "\n", "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "348f9bbd", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.001s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.001s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.002s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slept for 0.002s\n" - ] - }, - { - "data": { - "text/plain": [ - "{'status': 'done'}" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } ], + "id": "67a11c47e760e088" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "@rt\n", "def backgrounds3():\n", @@ -5069,14 +4276,14 @@ "r = cli.get('/backgrounds3')\n", "test_eq(r.status_code, 200)\n", "r.json()" - ] + ], + "id": "57f456b964a6c2b7" }, { - "cell_type": "code", - "execution_count": null, - "id": "9dc1025e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def unqid(seeded=False):\n", @@ -5084,28 +4291,28 @@ " id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4()\n", " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" - ] + ], + "id": "335006dfb096cead" }, { - "cell_type": "code", - "execution_count": null, - "id": "5b67e014", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _add_ids(s):\n", " if not isinstance(s, FT): return\n", " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" - ] + ], + "id": "637735f394562e3a" }, { - "cell_type": "code", - "execution_count": null, - "id": "1f590f25", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5118,14 +4325,14 @@ " for o in conns.values(): await o(s)\n", " app._send = send\n", " return send" - ] + ], + "id": "7740b43745222106" }, { - "cell_type": "code", - "execution_count": null, - "id": "a8a91edd", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "devtools_loc = \"/.well-known/appspecific/com.chrome.devtools.json\"\n", @@ -5136,14 +4343,14 @@ " if not uuid: uuid = str(uuid5(NAMESPACE_URL, str(path)))\n", " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" - ] + ], + "id": "c31e7509d1ca707" }, { - "cell_type": "code", - "execution_count": null, - "id": "e27908c0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5156,14 +4363,14 @@ " client = httpx2.AsyncClient() if asink else httpx2.Client()\n", " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" - ] + ], + "id": "96cfdb7d4d31f050" }, { - "cell_type": "code", - "execution_count": null, - "id": "c845f437", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5172,14 +4379,14 @@ " if not cookie or cookie == 'null': return {}\n", " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" - ] + ], + "id": "2d51142bf081806d" }, { - "cell_type": "code", - "execution_count": null, - "id": "49260e9b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5195,38 +4402,37 @@ " return self.app.decode_session(cookie)\n", "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" - ] + ], + "id": "6636f33a9b96f503" }, { - "cell_type": "code", - "execution_count": null, - "id": "6830083b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" - ] + ], + "id": "8141e15a8ce24b5f" }, { - "cell_type": "markdown", - "id": "474e14b4", "metadata": {}, - "source": [ - "## Export -" - ] + "cell_type": "markdown", + "source": "## Export -", + "id": "5551a408cd475b7" }, { - "cell_type": "code", - "execution_count": null, - "id": "d211e8e2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| hide\n", "import nbdev\n", "nbdev.nbdev_export()" - ] + ], + "id": "a48bde6e158344c0" } ], "metadata": { From f7089e11500154760034af2797993e58dcae4c28 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:18:20 -0300 Subject: [PATCH 06/22] test: add tests for request parameter alias handling --- tests/test_alias.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_alias.py diff --git a/tests/test_alias.py b/tests/test_alias.py new file mode 100644 index 00000000..059fe82b --- /dev/null +++ b/tests/test_alias.py @@ -0,0 +1,42 @@ +from fasthtml.common import * +from starlette.testclient import TestClient +from typing import Annotated + +app, rt = fast_app() +cli = TestClient(app) + +@rt("/query") +def get(user_id: Annotated[str, Alias("user-id")]): return f"q:{user_id}" + +@rt("/form", methods=["post"]) +def post(user_id: Annotated[str, Alias("user-id")]): return f"f:{user_id}" + +@rt("/path/{item_id}") +def get(item_id: Annotated[int, Alias("item_id")]): return f"p:{item_id}" + +@rt("/header") +def get(x_api_key: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key}" + +@rt("/cookie") +def get(req, session_id: Annotated[str, Alias("session-id")]): return f"c:{session_id}" + +@rt("/int") +def get(count: Annotated[int, Alias("the-count")]): return f"i:{count + 1}" + +@rt("/plain") +def get(user_id: str): return f"plain:{user_id}" + +@rt("/missing") +def get(user_id: Annotated[str, Alias("user-id")]): return user_id + +def test_query_alias(): assert cli.get('/query?user-id=abc').text == 'q:abc' +def test_form_alias(): assert cli.post('/form', data={'user-id': 'abc'}).text == 'f:abc' +def test_path_alias(): assert cli.get('/path/7').text == 'p:7' +def test_header_alias(): assert cli.get('/header', headers={'X-Api-Key': 'k1'}).text == 'h:k1' +def test_cookie_alias(): assert cli.get('/cookie', cookies={'session-id': 's1'}).text == 'c:s1' +def test_int_alias(): assert cli.get('/int?the-count=4').text == 'i:5' +def test_plain_unchanged(): assert cli.get('/plain?user_id=z').text == 'plain:z' +def test_snake_name_not_used_for_alias(): assert cli.get('/query?user_id=abc').status_code == 400 +def test_missing_message_uses_alias(): + r = cli.get('/missing') + assert r.status_code == 400 and 'Missing required field: user-id' in r.text From 078bf8399c645a6bebdc6fa33544e458bb639de6 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:43:08 -0300 Subject: [PATCH 07/22] refactor: update code cell metadata in 00_core.ipynb --- nbs/api/00_core.ipynb | 585 +++++++++++++++++++++--------------------- 1 file changed, 298 insertions(+), 287 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 95a8cf01..b1efc44d 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "7f686573b69acc40" + "id": "bc698490f70ad760" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "59798c88b0200b3c" + "id": "7cfa9456b28add27" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "ab4062d704000f87" + "id": "4a5c40cbd6b96a37" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "1acd20a02bd6d35a" + "id": "a70d5d3182ca303b" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "34bfc48a8b184299" + "id": "687079de57a42a34" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "53afb6589c3d143e" + "id": "74eb565a6ca22027" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "7410b06288e1fe26" + "id": "908ef33d5b949d61" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "43c1dfb348886a28" + "id": "c2e8bf6eec8515ea" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "994c8a6af6a4d489" + "id": "a48905ec29e84b05" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "459b5fc44b7c9d5e" + "id": "3db9e4e5a4a60f47" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "809da9c50103c9dd" + "id": "f0d0ab89fcf93351" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "5e5a75f6663c14e2" + "id": "d772a5c1a3e07e1b" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "fd499ac1bf5ca80c" + "id": "cbb1cc44abb83dea" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "788eebb4a93cd144" + "id": "45e928d3bf8a0127" }, { "metadata": {}, @@ -234,7 +234,7 @@ " res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "5c5266880608b6d1" + "id": "912545966705bf8b" }, { "metadata": {}, @@ -249,7 +249,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "a223b8b2def6c12f" + "id": "cdf830e0fbb7a8e5" }, { "metadata": {}, @@ -260,7 +260,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "76cb9df3f9560f26" + "id": "f767c1bb72d9d113" }, { "metadata": {}, @@ -271,13 +271,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "72e3945ec1dcb7ed" + "id": "63601ad8dd759b99" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "13f0432b5d256410" + "id": "31e875a70b7d36a5" }, { "metadata": {}, @@ -288,7 +288,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "64dbed7ee2309943" + "id": "cbc801d046d7d084" }, { "metadata": {}, @@ -304,7 +304,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "174eac262617a991" + "id": "a226ee3cccb2519e" }, { "metadata": {}, @@ -325,7 +325,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "b61bf0b9981ede39" + "id": "9ce04062c728e12d" }, { "metadata": {}, @@ -346,7 +346,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "1734c816b8331d79" + "id": "ede3b755b5964d1c" }, { "metadata": {}, @@ -364,7 +364,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "d1cd459b43ebdeb8" + "id": "e9cff1c16a6133bf" }, { "metadata": {}, @@ -377,7 +377,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "c56632e237956f69" + "id": "8fd54d1e73b50c65" }, { "metadata": {}, @@ -391,7 +391,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "e02eb8ef77a0ed27" + "id": "ccae4a3e3396de7c" }, { "metadata": {}, @@ -405,7 +405,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "20c90293417cb2af" + "id": "475fcdfb7f60b95f" }, { "metadata": {}, @@ -413,7 +413,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "64616ebfdd4b64bb" + "id": "80392b12e1020f0b" }, { "metadata": {}, @@ -428,7 +428,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "96914eebba4f02bc" + "id": "862adead1ff6fd91" }, { "metadata": {}, @@ -436,7 +436,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "2189b2baeb94dd0d" + "id": "9cf582da2f77eb69" }, { "metadata": {}, @@ -450,7 +450,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "d91ef3bd135dc3ee" + "id": "a31232134dbe2ac" }, { "metadata": {}, @@ -462,7 +462,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "d4be2f3056e3f950" + "id": "717938adccebe4f3" }, { "metadata": {}, @@ -473,7 +473,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "d2afd93effac57ad" + "id": "c49e5997904e333d" }, { "metadata": {}, @@ -484,7 +484,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "ffd54d552801d9a5" + "id": "acf8e46c0b78a844" }, { "metadata": {}, @@ -500,7 +500,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "9008844db08f88f2" + "id": "c47ddb9dca284510" }, { "metadata": {}, @@ -514,7 +514,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "cff090e30c5ca11a" + "id": "30a9fdb45d78cc71" }, { "metadata": {}, @@ -528,7 +528,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "8e046d5aa0ad19ec" + "id": "1620a03b5b53c9ae" }, { "metadata": {}, @@ -539,7 +539,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "319a6c62cc3e35ed" + "id": "f1b4827ca0fe2157" }, { "metadata": {}, @@ -566,13 +566,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "9c5cdcc778cc38a7" + "id": "1054d6149d8720ec" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "af8b65337d831e72" + "id": "2280bfd557b15769" }, { "metadata": {}, @@ -586,7 +586,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "a9a4f860d46ba408" + "id": "551de704c7ecce25" }, { "metadata": {}, @@ -600,7 +600,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "dd2182af86fb2578" + "id": "2d97d6131b208778" }, { "metadata": {}, @@ -624,7 +624,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "23f9fadd08c3c28c" + "id": "92dfc839766cc4a6" }, { "metadata": {}, @@ -641,13 +641,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "afa2e38d1b854c81" + "id": "fed371a8d4196a6e" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "ec73cbbd214917ea" + "id": "bf11cb6047e4bbcc" }, { "metadata": {}, @@ -663,7 +663,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "6fc0c7f6b7e307fe" + "id": "53fde9431c4751d9" }, { "metadata": {}, @@ -684,7 +684,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "91a217829d3e79f9" + "id": "42c96871a0ed3570" }, { "metadata": {}, @@ -701,13 +701,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "126d0dcf0eb89218" + "id": "e28834e8078136ba" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "bd7f8488c0910f52" + "id": "ae77b32f318ad7c2" }, { "metadata": {}, @@ -720,13 +720,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "71c4ef54948643a3" + "id": "ba7a4c7ff2044fba" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "f785db5c4a5d10f2" + "id": "4b4c9ac6bb612971" }, { "metadata": {}, @@ -740,7 +740,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "2079e9d02dd2e49c" + "id": "7f41ac48f4bd638" }, { "metadata": {}, @@ -752,7 +752,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "bc54b0a9ba395e42" + "id": "9764bf75c1614912" }, { "metadata": {}, @@ -820,7 +820,18 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "8e679f81c025a2e6" + "id": "e40a106c3f3f85fc" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "data = form2dict(await parse_form(req))\n", + "await _from_body(req, p, data)" + ], + "id": "92acee97a091aaa5" }, { "metadata": {}, @@ -833,7 +844,7 @@ " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "d98c2374a04c1424" + "id": "bf313685869a8006" }, { "metadata": {}, @@ -844,7 +855,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "1895949b223f331d" + "id": "a32b68e1d3bc55b0" }, { "metadata": {}, @@ -857,7 +868,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "e5f9b252b5404d6" + "id": "b419de64cde702c1" }, { "metadata": {}, @@ -877,7 +888,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "212c21983334789e" + "id": "1c872b17d28ab528" }, { "metadata": {}, @@ -895,7 +906,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "3aab2026066508b8" + "id": "4f9212b9a87ad40f" }, { "metadata": {}, @@ -913,7 +924,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "ad76da242022e3f4" + "id": "bdad60519a7d7b2e" }, { "metadata": {}, @@ -935,19 +946,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "ead1df177cd0001" + "id": "b5767c849a371a0e" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "db783b911aea310d" + "id": "db437a1989241169" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "8774cb8b857934eb" + "id": "de4ad9e40e66bfb7" }, { "metadata": {}, @@ -965,13 +976,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "624c7f99944813aa" + "id": "55c862659aadd36c" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "83ffd4d5f69ee8e3" + "id": "308deaafd80faab3" }, { "metadata": {}, @@ -989,7 +1000,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "454d528731449c" + "id": "659f5f100851b8bf" }, { "metadata": {}, @@ -1007,7 +1018,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "693922c3e46c7754" + "id": "c03c445284151c7c" }, { "metadata": {}, @@ -1019,7 +1030,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "581fbbf507982aae" + "id": "b194b91df7d94eeb" }, { "metadata": {}, @@ -1033,7 +1044,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "6192cec3c0df1bb3" + "id": "6b7dbc73c0ee9956" }, { "metadata": {}, @@ -1045,7 +1056,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "8ec98475a0a5c7d6" + "id": "baf21aa39d330caa" }, { "metadata": {}, @@ -1056,13 +1067,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "da268ae91d91d9bc" + "id": "dd14420eb9df34c3" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "92d3135c4ad6710f" + "id": "c7e4a0ee7bcfeeb7" }, { "metadata": {}, @@ -1076,7 +1087,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "5f2702274bb51006" + "id": "ff2e3560134d8625" }, { "metadata": {}, @@ -1112,7 +1123,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "9419cd0224cd98c2" + "id": "18b7eece28a161e6" }, { "metadata": {}, @@ -1128,7 +1139,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "bc7c575a83684d5b" + "id": "4ba9a76b79dde97b" }, { "metadata": {}, @@ -1143,7 +1154,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "42a74b181d3b6a8f" + "id": "3c9f31d17d2c4f38" }, { "metadata": {}, @@ -1156,7 +1167,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "741044b3e551225c" + "id": "e6863a75509ad30f" }, { "metadata": {}, @@ -1176,13 +1187,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "da2d506cff9cd847" + "id": "45597971e1e50d5e" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "a4eeb45c2f82f3ff" + "id": "7ec729996815fee2" }, { "metadata": {}, @@ -1195,7 +1206,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "184283f3ee5d49ba" + "id": "530eae6fbdd9539c" }, { "metadata": {}, @@ -1209,7 +1220,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "ccfedf12c80ff96" + "id": "e2a698806339055" }, { "metadata": {}, @@ -1220,7 +1231,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "fd09bc5e426f4a9c" + "id": "4807df742dbb80d1" }, { "metadata": {}, @@ -1238,7 +1249,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "7b7ad4249ea81211" + "id": "2d21bc37c076b635" }, { "metadata": {}, @@ -1252,7 +1263,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "70c8ec8b0a8ab19f" + "id": "770c2ff543026748" }, { "metadata": {}, @@ -1286,7 +1297,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "3f97f1ed8c754dff" + "id": "3c4b16489e6e3d54" }, { "metadata": {}, @@ -1297,7 +1308,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "fb23ee8a4bdf6ffc" + "id": "131028d5ce8bec84" }, { "metadata": {}, @@ -1316,7 +1327,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "f729e85becb6bbb6" + "id": "783da4cec6e383e5" }, { "metadata": {}, @@ -1329,7 +1340,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "3e5a0b56fa0c4857" + "id": "6d43e7902f33976c" }, { "metadata": {}, @@ -1346,7 +1357,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "b8e014df4ebb00b3" + "id": "a0a10c614c784090" }, { "metadata": {}, @@ -1355,7 +1366,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "c42f6fb487aceee5" + "id": "156bef4057dde28c" }, { "metadata": {}, @@ -1369,7 +1380,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "7ba6db1142901c4d" + "id": "e0f01932b6543d7c" }, { "metadata": {}, @@ -1394,7 +1405,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "58f36aba629b2268" + "id": "6412519625d5d511" }, { "metadata": {}, @@ -1418,7 +1429,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "a005b335ea8a4c04" + "id": "2348b0e1d611ff62" }, { "metadata": {}, @@ -1431,7 +1442,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "73c48c2cb1072a7e" + "id": "677001d8c8f92420" }, { "metadata": {}, @@ -1459,7 +1470,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "7c02e80fed13238e" + "id": "d2f2b62c8845f99c" }, { "metadata": {}, @@ -1475,7 +1486,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "896b46c4aa03192c" + "id": "688f836678719393" }, { "metadata": {}, @@ -1489,7 +1500,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "9b9774380fb5223c" + "id": "68a4d195b0f137e9" }, { "metadata": {}, @@ -1517,13 +1528,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "5df474a1953c5134" + "id": "b0b15a25a1f826d8" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "d2f9aef7815d59bb" + "id": "6b23ee6fde5e5b4b" }, { "metadata": {}, @@ -1531,7 +1542,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "fe8fdb5cd2c94da9" + "id": "e4fbd2b25f5cb6ae" }, { "metadata": {}, @@ -1548,7 +1559,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "ab5aae7c786f4db0" + "id": "953ab02f6f985c28" }, { "metadata": {}, @@ -1566,7 +1577,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "fd36cd44687c2273" + "id": "6054a224e78e9366" }, { "metadata": {}, @@ -1579,7 +1590,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "97ae79dd8df934d9" + "id": "57fd615f7415afa8" }, { "metadata": {}, @@ -1597,7 +1608,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "440490e465c515b4" + "id": "417f41f9a2b48a07" }, { "metadata": {}, @@ -1617,13 +1628,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "8c482e84ada42852" + "id": "3ac78cbb40aa5e1e" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "2b1d80a716c0adef" + "id": "d3c28cb2f39549c7" }, { "metadata": {}, @@ -1631,7 +1642,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "54be93910e6e66ba" + "id": "1e90b2548c207f5" }, { "metadata": {}, @@ -1642,13 +1653,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "a7000a851f7ec0b1" + "id": "4bb2dd90d99c0f69" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "9caeec84af6b57b9" + "id": "1af3b977e4b6e939" }, { "metadata": {}, @@ -1660,7 +1671,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "37b1595d560ee414" + "id": "14bcc36e075308b5" }, { "metadata": {}, @@ -1681,7 +1692,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "6a347c42c3a0b6b8" + "id": "8687262d8af34d50" }, { "metadata": {}, @@ -1714,7 +1725,7 @@ " };\n", "\"\"\"))\n" ], - "id": "7de7d4d9ce75b85d" + "id": "4c5e7deacc17f2dd" }, { "metadata": {}, @@ -1749,7 +1760,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "852e2ec0c0969f52" + "id": "5fcae44026df7b16" }, { "metadata": {}, @@ -1796,13 +1807,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "8e95f37034125b68" + "id": "e7a1793e81819075" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "9fc92122a2fa70f2" + "id": "6e4e5231cea40245" }, { "metadata": {}, @@ -1828,7 +1839,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "fe44229b6931a496" + "id": "fee720e4fcd081ab" }, { "metadata": {}, @@ -1848,7 +1859,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "3c04fe56abdc47fa" + "id": "6e3938b28bfa688f" }, { "metadata": {}, @@ -1864,7 +1875,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "654aac80d680d4eb" + "id": "68eee5aa5ce04ade" }, { "metadata": {}, @@ -1883,7 +1894,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "18175f9ddfa24df4" + "id": "761915f262a753d4" }, { "metadata": {}, @@ -1894,7 +1905,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "c00f976494575cc" + "id": "62590e9b8aa56156" }, { "metadata": {}, @@ -1931,7 +1942,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "4837ef8ced64c5e0" + "id": "8b820ee5ecd21e1c" }, { "metadata": {}, @@ -1949,7 +1960,7 @@ " self.add_route(route)\n", " return func" ], - "id": "154ea30b2e45f577" + "id": "d23d12b26b62e05" }, { "metadata": {}, @@ -1964,7 +1975,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "5655582e8b8129c6" + "id": "c6597341785b1226" }, { "metadata": {}, @@ -1978,7 +1989,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "bb318a080450cf8c" + "id": "e9208c48e6a7d964" }, { "metadata": {}, @@ -2000,7 +2011,7 @@ "\n", " return _lf()" ], - "id": "a294e823478edca7" + "id": "5803286e07e2dd6c" }, { "metadata": {}, @@ -2013,7 +2024,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "ee7941c6d76afd9e" + "id": "61504e932bcb6453" }, { "metadata": {}, @@ -2025,7 +2036,7 @@ " def g(): ...\n", " return g" ], - "id": "67a112ba5ba9ed0d" + "id": "459aa1caff394061" }, { "metadata": {}, @@ -2036,7 +2047,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "e04010a30af82a09" + "id": "c3311e187ab0c525" }, { "metadata": {}, @@ -2056,7 +2067,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "ebb18855aabae4a3" + "id": "79202eff3271765a" }, { "metadata": {}, @@ -2089,7 +2100,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "ce6f387e5f02d9db" + "id": "519bd9038c86b0d7" }, { "metadata": {}, @@ -2107,7 +2118,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "112b50b015b12164" + "id": "25ae3aa5f868f44a" }, { "metadata": {}, @@ -2121,7 +2132,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "dff8e8a4a56cf236" + "id": "b10bfcb7a593ee05" }, { "metadata": {}, @@ -2134,13 +2145,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "37009106fa07a942" + "id": "fbe6c053bf35cb87" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "16d546c2d4b56c7f" + "id": "f2ed69bbaf963475" }, { "metadata": {}, @@ -2155,7 +2166,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "7dadb462ba00d11c" + "id": "8395cef9c2dea921" }, { "metadata": {}, @@ -2189,13 +2200,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "c36b7185c1679ba5" + "id": "949365706b509933" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "b9e62008c275f97b" + "id": "944609fe9b5f5360" }, { "metadata": {}, @@ -2210,13 +2221,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "e35a3b4cdd465a37" + "id": "9923dbe3e6937f09" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "24c42a8e514a9e5f" + "id": "c6739d867595760d" }, { "metadata": {}, @@ -2239,13 +2250,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "42ca8bdf84326b3b" + "id": "d6f51e6ddabb5426" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "d6f88d179121010f" + "id": "8987f8795854b0d3" }, { "metadata": {}, @@ -2267,7 +2278,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "27f2e4665ebf5bbc" + "id": "9b289597ccc85bb0" }, { "metadata": {}, @@ -2288,7 +2299,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "8db5fe95c7d00bec" + "id": "2d9f445812e61010" }, { "metadata": {}, @@ -2301,19 +2312,19 @@ "\n", "cli.get('/').text" ], - "id": "ad12879dd1f19e35" + "id": "a0d50c343884f7" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "a67770455c7eb2b8" + "id": "acbf8b7311f723fd" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "f8a6739566557720" + "id": "1ace95eeec5d7233" }, { "metadata": {}, @@ -2321,7 +2332,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "563e9f037bfba8a1" + "id": "11278015a711f50" }, { "metadata": {}, @@ -2329,7 +2340,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "541824a436755f55" + "id": "876cfcb58fff09be" }, { "metadata": {}, @@ -2348,7 +2359,7 @@ "\n", "foo.to(param='value')" ], - "id": "c639800adf059ebf" + "id": "d223d27ab2024726" }, { "metadata": {}, @@ -2364,7 +2375,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "84739e25115b6885" + "id": "e9ad23318cd58f72" }, { "metadata": {}, @@ -2377,7 +2388,7 @@ "\n", "foo.to(param='value')" ], - "id": "a9ab959322b3c533" + "id": "845c8d8cd8b678e2" }, { "metadata": {}, @@ -2393,7 +2404,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "4b7beef96901eff5" + "id": "ad7c328b17fdf13b" }, { "metadata": {}, @@ -2405,7 +2416,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "772f8f233bfdf3a0" + "id": "840a147f46b96daa" }, { "metadata": {}, @@ -2427,7 +2438,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "a3cba3e1f15e799a" + "id": "14d777ba36c4523e" }, { "metadata": {}, @@ -2442,7 +2453,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "97b51681c70ff08" + "id": "89010e5a319a1ee" }, { "metadata": {}, @@ -2456,7 +2467,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "b0d1876823809add" + "id": "ab04bc8df2f0627c" }, { "metadata": {}, @@ -2469,7 +2480,7 @@ "\n", "cli.post('/hi').text" ], - "id": "afc2ab1803fa418a" + "id": "87f365d396f69614" }, { "metadata": {}, @@ -2482,7 +2493,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "a475f9d4844d8a3f" + "id": "c2f7aff939dc9838" }, { "metadata": {}, @@ -2505,7 +2516,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "3177015fbac381ec" + "id": "67c8fd44c79ba121" }, { "metadata": {}, @@ -2518,7 +2529,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "6b0d7ef1d4bd445d" + "id": "11bf1da78fa14eed" }, { "metadata": {}, @@ -2530,7 +2541,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "7c411a85a21ad08e" + "id": "bf550a863e622bb1" }, { "metadata": {}, @@ -2542,7 +2553,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "8c38b939291ab644" + "id": "5c264fec32f081e0" }, { "metadata": {}, @@ -2554,7 +2565,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "a77a1f47c56e36d9" + "id": "cb14ea89414940fb" }, { "metadata": {}, @@ -2566,7 +2577,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "3c20651b84de0f59" + "id": "561f7d3538fc1228" }, { "metadata": {}, @@ -2578,7 +2589,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "2c788900a2ccdb73" + "id": "418113e5fa132dd5" }, { "metadata": {}, @@ -2591,7 +2602,7 @@ "\n", "cli.get('/link').text" ], - "id": "f657a1f73d5dfd2f" + "id": "3909c1dd3e3392c4" }, { "metadata": {}, @@ -2608,7 +2619,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "8635c1055169bedf" + "id": "90716a3aea939978" }, { "metadata": {}, @@ -2616,7 +2627,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "b01c37683cd5749d" + "id": "1966de3a3b6b0b51" }, { "metadata": {}, @@ -2646,7 +2657,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "985d18d4a0b94a29" + "id": "338a57122f4d7622" }, { "metadata": {}, @@ -2660,7 +2671,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "2ce18b792ac77487" + "id": "224151e95a080e42" }, { "metadata": {}, @@ -2672,7 +2683,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "26ea68a93689fd0b" + "id": "ed0f5226db71a55b" }, { "metadata": {}, @@ -2683,7 +2694,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "e94e2e251db62443" + "id": "f8abfe73aa7e665b" }, { "metadata": {}, @@ -2694,7 +2705,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "32fa349b79d75e70" + "id": "c9be8b9a13c7b" }, { "metadata": {}, @@ -2720,7 +2731,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "a4b214dffb848f61" + "id": "c09b199ed001f5c7" }, { "metadata": {}, @@ -2741,7 +2752,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "5496149d1a02c20d" + "id": "ec2f7f8a97792fd" }, { "metadata": {}, @@ -2758,7 +2769,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "855956c3bfb4adee" + "id": "806bd5c5b8214027" }, { "metadata": {}, @@ -2766,7 +2777,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "97b1292db472196b" + "id": "41ad4d0dae24e431" }, { "metadata": {}, @@ -2774,7 +2785,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "ea361055b7188aff" + "id": "270b73ac388beec3" }, { "metadata": {}, @@ -2809,7 +2820,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "7f15062539128c48" + "id": "5bb28848fd83279f" }, { "metadata": {}, @@ -2826,7 +2837,7 @@ "test_r(cli, '/hxtest2', '1', headers={'HX-Request':'1'})\n", "test_r(cli, '/app' , 'foo')" ], - "id": "944594c4209fdc7c" + "id": "46d098bb8867c394" }, { "metadata": {}, @@ -2838,7 +2849,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "b4689d6ff7d4123a" + "id": "cf42935badd43f5f" }, { "metadata": {}, @@ -2849,7 +2860,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "295ae52452b974b6" + "id": "925cf36bab1b36bd" }, { "metadata": {}, @@ -2860,7 +2871,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "5dfa9fdf9be7c0ab" + "id": "57f7ee8411e7b167" }, { "metadata": {}, @@ -2871,7 +2882,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "c25ee2b4ae9af9db" + "id": "8dd137ec345034ec" }, { "metadata": {}, @@ -2886,7 +2897,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "f8c5a79a9e268cad" + "id": "ec3b771e001ee9c7" }, { "metadata": {}, @@ -3014,7 +3025,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "1c271ec1f072e066" + "id": "98e17f17ef0dc78a" }, { "metadata": {}, @@ -3022,7 +3033,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "93a69784d0a2b6b3" + "id": "ec6e5345e542ab55" }, { "metadata": {}, @@ -3037,7 +3048,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "8f88c027dbebba62" + "id": "2c61b6a60b3540d6" }, { "metadata": {}, @@ -3049,7 +3060,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "a1705546441529ca" + "id": "7ec54f9bf1c48765" }, { "metadata": {}, @@ -3071,7 +3082,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "ad0e269042b27e7e" + "id": "aad9b9ce36863a63" }, { "metadata": {}, @@ -3091,7 +3102,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "f5ca9e1ef115a62a" + "id": "34c2f446da12947a" }, { "metadata": {}, @@ -3104,7 +3115,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "409b7720eb224b52" + "id": "c92b006916962f75" }, { "metadata": {}, @@ -3117,7 +3128,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "f343fe095bb6667" + "id": "ec51f25bd3652cd1" }, { "metadata": {}, @@ -3128,7 +3139,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "c079a9480076de20" + "id": "d460e6dbdb8dd934" }, { "metadata": {}, @@ -3136,7 +3147,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "c3a0b4e98373614e" + "id": "3d7dbed27003480a" }, { "metadata": {}, @@ -3152,7 +3163,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "2f8b30794712e0c2" + "id": "864cf99f2c3ae7e4" }, { "metadata": {}, @@ -3168,7 +3179,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "90ba5b8403a3879d" + "id": "ec071d4434f7b01e" }, { "metadata": {}, @@ -3185,7 +3196,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "cd1418adab7bc3b1" + "id": "f5690664c11111c3" }, { "metadata": {}, @@ -3202,13 +3213,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "3d6b883e14f63a74" + "id": "35906bc86879e702" }, { "metadata": {}, "cell_type": "markdown", "source": "Check that afterware like toasts are called correctly.", - "id": "504abfeb5c1f9a9c" + "id": "715da9a9f886acdb" }, { "metadata": {}, @@ -3216,7 +3227,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "bf8618699f50aed1" + "id": "e097e6f2f34810bc" }, { "metadata": {}, @@ -3258,13 +3269,13 @@ "r = cli.get('/')\n", "assert tm in r.text" ], - "id": "8d549d9a5c93d99d" + "id": "a1b6328777990e33" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "9fab408e2e6e686" + "id": "71feed8c8eec723c" }, { "metadata": {}, @@ -3272,7 +3283,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "4bd5e983e3d3af53" + "id": "37285b1359490d0a" }, { "metadata": {}, @@ -3289,7 +3300,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "f5bd3ba8f7e070c1" + "id": "3911e2efa6eff2f7" }, { "metadata": {}, @@ -3307,13 +3318,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "e1e0a2981bdd9bed" + "id": "31ea8e937c9f18cb" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "e186cdcbef7aa053" + "id": "913d1b3cb80c4d0c" }, { "metadata": {}, @@ -3335,13 +3346,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "375bfb6e0574c3b1" + "id": "42b7166381b2de31" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "59fd36c5878e8631" + "id": "f827e72640085b2f" }, { "metadata": {}, @@ -3357,13 +3368,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "1b6335c5da6b5da4" + "id": "63ab236a7e4c8b5e" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "b8218676a7553fae" + "id": "ecba79afd0c3b9e8" }, { "metadata": {}, @@ -3383,13 +3394,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "ff2355f5ac88a72d" + "id": "b834f3053cdaaff5" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "80260448c4476ee0" + "id": "86b74f597dc5d2e9" }, { "metadata": {}, @@ -3405,7 +3416,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "b2148b69758de6f" + "id": "e64d3e485aa691fd" }, { "metadata": {}, @@ -3421,13 +3432,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "ac7273a868548b84" + "id": "d11b1608d51eb867" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "5819b50f4728bc55" + "id": "78bcb538beffc3c1" }, { "metadata": {}, @@ -3452,13 +3463,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "6dd453e187a01b13" + "id": "d9b6647ef0b1c120" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "cb43430858dcac10" + "id": "a3ef3aafeed1c543" }, { "metadata": {}, @@ -3477,7 +3488,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "6d212239ee4b3ebb" + "id": "8d1ad3aa405f8952" }, { "metadata": {}, @@ -3525,7 +3536,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "c069560553ea4e31" + "id": "93fc46fe69d90aa2" }, { "metadata": {}, @@ -3533,7 +3544,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "7053f84ef1ee93f6" + "id": "b5410d5e8e309c99" }, { "metadata": {}, @@ -3563,7 +3574,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "c0ff4dc1dfe83148" + "id": "2bf8cf65a3dd1945" }, { "metadata": {}, @@ -3574,7 +3585,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "d78e389d3610d965" + "id": "8b3f8ed2df1bde26" }, { "metadata": {}, @@ -3592,7 +3603,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "c8ebe7cfdc63718" + "id": "73718966d4787400" }, { "metadata": {}, @@ -3613,7 +3624,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "6a60b59b118846a6" + "id": "5acb58e120365823" }, { "metadata": {}, @@ -3626,7 +3637,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "b8e12162ea3e310c" + "id": "5ac26b06271b614" }, { "metadata": {}, @@ -3634,7 +3645,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "34dc5eb7b3152b1c" + "id": "26ba77d9d7360fe8" }, { "metadata": {}, @@ -3662,7 +3673,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "5ea40de23ef540fd" + "id": "8be654b68b7e4fd6" }, { "metadata": {}, @@ -3673,7 +3684,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "50efe1782d71c63d" + "id": "71d836196c32426d" }, { "metadata": {}, @@ -3690,7 +3701,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "5e76acbce9b744e9" + "id": "b4acf9b9badda242" }, { "metadata": {}, @@ -3709,7 +3720,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "74d0e896f59faa34" + "id": "1a999d4fe38dc396" }, { "metadata": {}, @@ -3722,7 +3733,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "3f575fb7222c54cd" + "id": "a3201869573175c1" }, { "metadata": {}, @@ -3733,7 +3744,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "6d6695412ac8dccd" + "id": "1859d1c226783abe" }, { "metadata": {}, @@ -3755,13 +3766,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "2fd1f4a196f51927" + "id": "3f2e9dfaf07906ad" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "d14d79e176792cd8" + "id": "cefabdfd3804ce65" }, { "metadata": {}, @@ -3769,7 +3780,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "710d59fe07e76a4e" + "id": "f4f134d385ba979c" }, { "metadata": {}, @@ -3794,7 +3805,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "4b8cabb822ba5c06" + "id": "8db47f8de086a404" }, { "metadata": {}, @@ -3812,7 +3823,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "9020523bd7e93f" + "id": "b83427dfe5a39ae8" }, { "metadata": {}, @@ -3826,7 +3837,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "5cdac562bf659976" + "id": "d18e6ecc94583abc" }, { "metadata": {}, @@ -3846,7 +3857,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "f39ce8a9974eb311" + "id": "88b7340a78a5e97c" }, { "metadata": {}, @@ -3861,7 +3872,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "652c879213bfb742" + "id": "dd357f05f363eddb" }, { "metadata": {}, @@ -3872,7 +3883,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "14ac1dcb187e9ded" + "id": "7e6039c9c6cc9e" }, { "metadata": {}, @@ -3887,7 +3898,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "f3b305241d618c09" + "id": "4433cd8119244424" }, { "metadata": {}, @@ -3898,7 +3909,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "a71a0eb39efe8d5c" + "id": "2f661b66f3e94173" }, { "metadata": {}, @@ -3914,7 +3925,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "dfc415e286f4b8" + "id": "7dd76a7c38bf4ec0" }, { "metadata": {}, @@ -3930,13 +3941,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "23fae82950b80317" + "id": "341dff8d4f8feb92" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "58f59f41ea0160e0" + "id": "70c4f465f1a1e4d9" }, { "metadata": {}, @@ -3951,7 +3962,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "d107a4932dcdd8a" + "id": "78397e0f041db120" }, { "metadata": {}, @@ -3970,13 +3981,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "17e13907ee30fc50" + "id": "91e627079c83160d" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "b72b1bfca5946f5b" + "id": "bc9f863ad8101d60" }, { "metadata": {}, @@ -3996,7 +4007,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "8215a537819de8bd" + "id": "35c4396c812bdbbb" }, { "metadata": {}, @@ -4008,7 +4019,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "1a6cbfc05998c35d" + "id": "e77d8aa644feefcd" }, { "metadata": {}, @@ -4027,7 +4038,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "66cc946f0f8a8894" + "id": "97d0447b3fe8d090" }, { "metadata": {}, @@ -4057,19 +4068,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "ffee8e07c9b7665d" + "id": "d9d0b14c50a4ee20" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "1d7a5679528c39d" + "id": "170d28cff6e4799a" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "fa58fb4fd02c2db0" + "id": "da98f752664d3c67" }, { "metadata": {}, @@ -4085,13 +4096,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "3e1fe925afb7969" + "id": "ef603fee8f8e74f9" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "6d3457d3d02d70e" + "id": "c9e190baca5d582c" }, { "metadata": {}, @@ -4103,13 +4114,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "2e12f2654d9f9548" + "id": "af10b39dcbaf6fcf" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "d16aabdb9df9fc0d" + "id": "dc4f32eee6a84a4" }, { "metadata": {}, @@ -4121,7 +4132,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "f813fe19102b7fc7" + "id": "9b9ac10381a145ae" }, { "metadata": {}, @@ -4137,7 +4148,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "621de4f72f855409" + "id": "7a5ebb738f694d75" }, { "metadata": {}, @@ -4160,7 +4171,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "a19aeb513a26811d" + "id": "41b3a970fb0d5b7c" }, { "metadata": {}, @@ -4180,13 +4191,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "97242b82a893fb30" + "id": "4bd5d1f492918b6a" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "cbc9c706e4bf24c6" + "id": "6df76d2a9608cbdc" }, { "metadata": {}, @@ -4207,13 +4218,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "dcd54920e1965347" + "id": "d08eb5f58af30580" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "9400f23cc2ac6adc" + "id": "99c9fd088ff2c315" }, { "metadata": {}, @@ -4227,7 +4238,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "20ea3030ace925f8" + "id": "a5502eff873e0492" }, { "metadata": {}, @@ -4244,7 +4255,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "eab10bb45ede6f25" + "id": "1536be70e3244b35" }, { "metadata": {}, @@ -4260,7 +4271,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "67a11c47e760e088" + "id": "d4bca91cd874fc15" }, { "metadata": {}, @@ -4277,7 +4288,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "57f456b964a6c2b7" + "id": "e20e7c963bf0e8d3" }, { "metadata": {}, @@ -4292,7 +4303,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "335006dfb096cead" + "id": "eabef5efb84fbbce" }, { "metadata": {}, @@ -4306,7 +4317,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "637735f394562e3a" + "id": "7b02ac1afad917b7" }, { "metadata": {}, @@ -4326,7 +4337,7 @@ " app._send = send\n", " return send" ], - "id": "7740b43745222106" + "id": "3214911e487f8536" }, { "metadata": {}, @@ -4344,7 +4355,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "c31e7509d1ca707" + "id": "9b073cb96670c884" }, { "metadata": {}, @@ -4364,7 +4375,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "96cfdb7d4d31f050" + "id": "d9fc5093f40be209" }, { "metadata": {}, @@ -4380,7 +4391,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "2d51142bf081806d" + "id": "da949a3f6dd16b6c" }, { "metadata": {}, @@ -4403,7 +4414,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "6636f33a9b96f503" + "id": "a631620ade78cb51" }, { "metadata": {}, @@ -4414,13 +4425,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "8141e15a8ce24b5f" + "id": "9a572b597b4142d6" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "5551a408cd475b7" + "id": "87aff436308386ab" }, { "metadata": {}, @@ -4432,7 +4443,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "a48bde6e158344c0" + "id": "8e4a52eca26ad422" } ], "metadata": { From 2baf89574b010421d32acd06734feeb30dd8b55b Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 17:06:04 -0300 Subject: [PATCH 08/22] refactor: update code cell structure --- nbs/api/00_core.ipynb | 576 +++++++++++++++++++++--------------------- 1 file changed, 288 insertions(+), 288 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index b1efc44d..b0c3165e 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "bc698490f70ad760" + "id": "abd0715bf7152ac0" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "7cfa9456b28add27" + "id": "bca716270de06332" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "4a5c40cbd6b96a37" + "id": "e0208107ef170c3a" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "a70d5d3182ca303b" + "id": "b6fcb0a721031c64" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "687079de57a42a34" + "id": "b8c87c3bdf8337e1" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "74eb565a6ca22027" + "id": "d60896b1be9a376b" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "908ef33d5b949d61" + "id": "aba058a56d848e71" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "c2e8bf6eec8515ea" + "id": "109f63591961b4b7" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "a48905ec29e84b05" + "id": "21a75d01b7bdb039" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "3db9e4e5a4a60f47" + "id": "ef535f00a2a89f4b" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "f0d0ab89fcf93351" + "id": "bc2cb214de4fcf1d" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "d772a5c1a3e07e1b" + "id": "a26820d3b1c9070b" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "cbb1cc44abb83dea" + "id": "5fb1e4617a486fa5" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "45e928d3bf8a0127" + "id": "19519192103191cb" }, { "metadata": {}, @@ -234,7 +234,7 @@ " res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "912545966705bf8b" + "id": "2be18465e895ee76" }, { "metadata": {}, @@ -249,7 +249,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "cdf830e0fbb7a8e5" + "id": "55bd364a9292ab0a" }, { "metadata": {}, @@ -260,7 +260,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "f767c1bb72d9d113" + "id": "6992101105b572e2" }, { "metadata": {}, @@ -271,13 +271,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "63601ad8dd759b99" + "id": "dcb0d4d0e48ecdb0" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "31e875a70b7d36a5" + "id": "7826b57de89c3067" }, { "metadata": {}, @@ -288,7 +288,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "cbc801d046d7d084" + "id": "43fff0ca21fc8aee" }, { "metadata": {}, @@ -304,7 +304,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "a226ee3cccb2519e" + "id": "305a6017196e36c3" }, { "metadata": {}, @@ -325,7 +325,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "9ce04062c728e12d" + "id": "8209bd0bd8a2c826" }, { "metadata": {}, @@ -346,7 +346,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "ede3b755b5964d1c" + "id": "2143de2a74a16ce5" }, { "metadata": {}, @@ -364,7 +364,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "e9cff1c16a6133bf" + "id": "7b2b72d8228d8f14" }, { "metadata": {}, @@ -377,7 +377,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "8fd54d1e73b50c65" + "id": "69ac6def9a2e62c2" }, { "metadata": {}, @@ -391,7 +391,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "ccae4a3e3396de7c" + "id": "b01823074c702f33" }, { "metadata": {}, @@ -405,7 +405,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "475fcdfb7f60b95f" + "id": "564d73a5396b8e8" }, { "metadata": {}, @@ -413,7 +413,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "80392b12e1020f0b" + "id": "c8c89531103e4c60" }, { "metadata": {}, @@ -428,7 +428,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "862adead1ff6fd91" + "id": "be6f8e38e13cfef4" }, { "metadata": {}, @@ -436,7 +436,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "9cf582da2f77eb69" + "id": "f6b9835d1571f588" }, { "metadata": {}, @@ -450,7 +450,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "a31232134dbe2ac" + "id": "10208f86a5c75153" }, { "metadata": {}, @@ -462,7 +462,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "717938adccebe4f3" + "id": "555a8762a6dae6c7" }, { "metadata": {}, @@ -473,7 +473,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "c49e5997904e333d" + "id": "f01ad1b910e68295" }, { "metadata": {}, @@ -484,7 +484,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "acf8e46c0b78a844" + "id": "3309bcdace4b0ee0" }, { "metadata": {}, @@ -500,7 +500,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "c47ddb9dca284510" + "id": "559151c1542e7b2c" }, { "metadata": {}, @@ -514,7 +514,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "30a9fdb45d78cc71" + "id": "28c6f85adea7b96" }, { "metadata": {}, @@ -528,7 +528,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "1620a03b5b53c9ae" + "id": "2101f06572504ca7" }, { "metadata": {}, @@ -539,7 +539,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "f1b4827ca0fe2157" + "id": "6119a9f086e5855" }, { "metadata": {}, @@ -566,13 +566,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "1054d6149d8720ec" + "id": "b7a84fdc1e959dea" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "2280bfd557b15769" + "id": "ec4b6bec4e8e794e" }, { "metadata": {}, @@ -586,7 +586,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "551de704c7ecce25" + "id": "dc9b6f86c7944766" }, { "metadata": {}, @@ -600,7 +600,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "2d97d6131b208778" + "id": "b8b66eac349389ef" }, { "metadata": {}, @@ -624,7 +624,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "92dfc839766cc4a6" + "id": "681bd327beec6e72" }, { "metadata": {}, @@ -641,13 +641,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "fed371a8d4196a6e" + "id": "da86134ea3f91a3" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "bf11cb6047e4bbcc" + "id": "29ea7f7ef01624e0" }, { "metadata": {}, @@ -663,7 +663,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "53fde9431c4751d9" + "id": "745df49205bec54d" }, { "metadata": {}, @@ -684,7 +684,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "42c96871a0ed3570" + "id": "d651326e47868a29" }, { "metadata": {}, @@ -701,13 +701,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "e28834e8078136ba" + "id": "4a4add89b5f4582a" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "ae77b32f318ad7c2" + "id": "f0aaf06139498d27" }, { "metadata": {}, @@ -720,13 +720,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "ba7a4c7ff2044fba" + "id": "c3c8f64bd923df80" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "4b4c9ac6bb612971" + "id": "811f769f640298cc" }, { "metadata": {}, @@ -740,7 +740,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "7f41ac48f4bd638" + "id": "11cb9db4b4adb623" }, { "metadata": {}, @@ -752,7 +752,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "9764bf75c1614912" + "id": "28ad1a98e6242bd" }, { "metadata": {}, @@ -820,7 +820,7 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "e40a106c3f3f85fc" + "id": "76fd36997784bcc1" }, { "metadata": {}, @@ -831,7 +831,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "92acee97a091aaa5" + "id": "7238bfa41ddecf3a" }, { "metadata": {}, @@ -844,7 +844,7 @@ " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "bf313685869a8006" + "id": "9fc8463265ad00e" }, { "metadata": {}, @@ -855,7 +855,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "a32b68e1d3bc55b0" + "id": "d391f205f3d13615" }, { "metadata": {}, @@ -868,7 +868,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "b419de64cde702c1" + "id": "2cf24e42249b1531" }, { "metadata": {}, @@ -888,7 +888,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "1c872b17d28ab528" + "id": "c37802ba27f517fb" }, { "metadata": {}, @@ -906,7 +906,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "4f9212b9a87ad40f" + "id": "7c0d0a599755e4ef" }, { "metadata": {}, @@ -924,7 +924,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "bdad60519a7d7b2e" + "id": "ce987784ddbf635b" }, { "metadata": {}, @@ -946,19 +946,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "b5767c849a371a0e" + "id": "e2a191995d5e49c5" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "db437a1989241169" + "id": "f306e1884f97945a" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "de4ad9e40e66bfb7" + "id": "34730ee3ccdb005a" }, { "metadata": {}, @@ -976,13 +976,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "55c862659aadd36c" + "id": "e214b6fdae0d2aab" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "308deaafd80faab3" + "id": "73ef231f8373b915" }, { "metadata": {}, @@ -1000,7 +1000,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "659f5f100851b8bf" + "id": "a963b3c64f47a9c7" }, { "metadata": {}, @@ -1018,7 +1018,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "c03c445284151c7c" + "id": "a9805aa4ff9a95c5" }, { "metadata": {}, @@ -1030,7 +1030,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "b194b91df7d94eeb" + "id": "37ba5f0377236658" }, { "metadata": {}, @@ -1044,7 +1044,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "6b7dbc73c0ee9956" + "id": "6174dca6668a1fb1" }, { "metadata": {}, @@ -1056,7 +1056,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "baf21aa39d330caa" + "id": "a96c2774bcd04b43" }, { "metadata": {}, @@ -1067,13 +1067,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "dd14420eb9df34c3" + "id": "2d0bc4359aa6fe36" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "c7e4a0ee7bcfeeb7" + "id": "f6bb5d02c6c8864e" }, { "metadata": {}, @@ -1087,7 +1087,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "ff2e3560134d8625" + "id": "153e3b1ea7055292" }, { "metadata": {}, @@ -1123,7 +1123,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "18b7eece28a161e6" + "id": "d0bb74c0505394bf" }, { "metadata": {}, @@ -1139,7 +1139,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "4ba9a76b79dde97b" + "id": "f15042a7a6b760a3" }, { "metadata": {}, @@ -1154,7 +1154,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "3c9f31d17d2c4f38" + "id": "9188bc7533b849a9" }, { "metadata": {}, @@ -1167,7 +1167,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "e6863a75509ad30f" + "id": "9f75ad3f2f434549" }, { "metadata": {}, @@ -1187,13 +1187,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "45597971e1e50d5e" + "id": "10ef6b0f6859260b" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "7ec729996815fee2" + "id": "e289aa9f0018eaf2" }, { "metadata": {}, @@ -1206,7 +1206,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "530eae6fbdd9539c" + "id": "653fd1e031e7e909" }, { "metadata": {}, @@ -1220,7 +1220,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "e2a698806339055" + "id": "7b5165962e556c08" }, { "metadata": {}, @@ -1231,7 +1231,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "4807df742dbb80d1" + "id": "c97237df628aa637" }, { "metadata": {}, @@ -1249,7 +1249,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "2d21bc37c076b635" + "id": "788114a9c471742a" }, { "metadata": {}, @@ -1263,7 +1263,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "770c2ff543026748" + "id": "28bdbabfab30eeb9" }, { "metadata": {}, @@ -1297,7 +1297,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "3c4b16489e6e3d54" + "id": "6b905eb0992d1f6c" }, { "metadata": {}, @@ -1308,7 +1308,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "131028d5ce8bec84" + "id": "cc183ea253dc4a5" }, { "metadata": {}, @@ -1327,7 +1327,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "783da4cec6e383e5" + "id": "bd5994ad1f97d4dc" }, { "metadata": {}, @@ -1340,7 +1340,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "6d43e7902f33976c" + "id": "2f0ed8c4a7f04612" }, { "metadata": {}, @@ -1357,7 +1357,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "a0a10c614c784090" + "id": "9acad59a7635407e" }, { "metadata": {}, @@ -1366,7 +1366,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "156bef4057dde28c" + "id": "e8c2481ee1749003" }, { "metadata": {}, @@ -1380,7 +1380,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "e0f01932b6543d7c" + "id": "26de0211057e7bc" }, { "metadata": {}, @@ -1405,7 +1405,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "6412519625d5d511" + "id": "d5f178af1a5d2f5f" }, { "metadata": {}, @@ -1429,7 +1429,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "2348b0e1d611ff62" + "id": "2db5544ac44ffbee" }, { "metadata": {}, @@ -1442,7 +1442,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "677001d8c8f92420" + "id": "4a494dc0787a67c2" }, { "metadata": {}, @@ -1470,7 +1470,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "d2f2b62c8845f99c" + "id": "b10f16ab777cd471" }, { "metadata": {}, @@ -1486,7 +1486,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "688f836678719393" + "id": "1a4b6968c2791fd9" }, { "metadata": {}, @@ -1500,7 +1500,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "68a4d195b0f137e9" + "id": "b1b70e9d57fe2224" }, { "metadata": {}, @@ -1528,13 +1528,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "b0b15a25a1f826d8" + "id": "9dc2a72097bb73cc" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "6b23ee6fde5e5b4b" + "id": "a44de325777a4e85" }, { "metadata": {}, @@ -1542,7 +1542,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "e4fbd2b25f5cb6ae" + "id": "35d1048f4bc1cca6" }, { "metadata": {}, @@ -1559,7 +1559,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "953ab02f6f985c28" + "id": "7de4b3490f3ff8a1" }, { "metadata": {}, @@ -1577,7 +1577,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "6054a224e78e9366" + "id": "55027657b8ad63e2" }, { "metadata": {}, @@ -1590,7 +1590,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "57fd615f7415afa8" + "id": "4cccd862800e7ac" }, { "metadata": {}, @@ -1608,7 +1608,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "417f41f9a2b48a07" + "id": "7e1d96be2a459ff7" }, { "metadata": {}, @@ -1628,13 +1628,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "3ac78cbb40aa5e1e" + "id": "c29274682fe83710" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "d3c28cb2f39549c7" + "id": "5911decec03b9204" }, { "metadata": {}, @@ -1642,7 +1642,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "1e90b2548c207f5" + "id": "6d5f08ed6b8d7eab" }, { "metadata": {}, @@ -1653,13 +1653,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "4bb2dd90d99c0f69" + "id": "7ea36e9f3118cc31" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "1af3b977e4b6e939" + "id": "e9445227a177b6e9" }, { "metadata": {}, @@ -1671,7 +1671,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "14bcc36e075308b5" + "id": "20b8c42db7ceebac" }, { "metadata": {}, @@ -1692,7 +1692,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "8687262d8af34d50" + "id": "61d229aa49526f9e" }, { "metadata": {}, @@ -1725,7 +1725,7 @@ " };\n", "\"\"\"))\n" ], - "id": "4c5e7deacc17f2dd" + "id": "9e9245d7151846d5" }, { "metadata": {}, @@ -1760,7 +1760,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "5fcae44026df7b16" + "id": "922ae73cd9942576" }, { "metadata": {}, @@ -1807,13 +1807,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "e7a1793e81819075" + "id": "8280ab77b004c7fd" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "6e4e5231cea40245" + "id": "87f24b23458952ab" }, { "metadata": {}, @@ -1839,7 +1839,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "fee720e4fcd081ab" + "id": "cbced2560121728f" }, { "metadata": {}, @@ -1859,7 +1859,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "6e3938b28bfa688f" + "id": "53915be81ac4c15b" }, { "metadata": {}, @@ -1875,7 +1875,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "68eee5aa5ce04ade" + "id": "28de7f0b0f018e4f" }, { "metadata": {}, @@ -1894,7 +1894,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "761915f262a753d4" + "id": "a0353d195d85b41f" }, { "metadata": {}, @@ -1905,7 +1905,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "62590e9b8aa56156" + "id": "35e92f834eaf7838" }, { "metadata": {}, @@ -1942,7 +1942,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "8b820ee5ecd21e1c" + "id": "8dc6cc8ead47bada" }, { "metadata": {}, @@ -1960,7 +1960,7 @@ " self.add_route(route)\n", " return func" ], - "id": "d23d12b26b62e05" + "id": "3807657597615741" }, { "metadata": {}, @@ -1975,7 +1975,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "c6597341785b1226" + "id": "ec1666b6c1071021" }, { "metadata": {}, @@ -1989,7 +1989,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "e9208c48e6a7d964" + "id": "f9de41431ec94bdc" }, { "metadata": {}, @@ -2011,7 +2011,7 @@ "\n", " return _lf()" ], - "id": "5803286e07e2dd6c" + "id": "3c2e5449180b4487" }, { "metadata": {}, @@ -2024,7 +2024,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "61504e932bcb6453" + "id": "afd2d5e724c31a07" }, { "metadata": {}, @@ -2036,7 +2036,7 @@ " def g(): ...\n", " return g" ], - "id": "459aa1caff394061" + "id": "d8c0c55c5dad21e5" }, { "metadata": {}, @@ -2047,7 +2047,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "c3311e187ab0c525" + "id": "a4130fc1830f5d5" }, { "metadata": {}, @@ -2067,7 +2067,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "79202eff3271765a" + "id": "27991d742be3027b" }, { "metadata": {}, @@ -2100,7 +2100,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "519bd9038c86b0d7" + "id": "17141c4b6b43dab4" }, { "metadata": {}, @@ -2118,7 +2118,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "25ae3aa5f868f44a" + "id": "71c2e5b68940c3a" }, { "metadata": {}, @@ -2132,7 +2132,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "b10bfcb7a593ee05" + "id": "ee00e8824e619da9" }, { "metadata": {}, @@ -2145,13 +2145,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "fbe6c053bf35cb87" + "id": "404c3c2a9a208c00" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "f2ed69bbaf963475" + "id": "f4458f66fb534b88" }, { "metadata": {}, @@ -2166,7 +2166,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "8395cef9c2dea921" + "id": "164ae4c080e3a203" }, { "metadata": {}, @@ -2200,13 +2200,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "949365706b509933" + "id": "574599ab8e485128" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "944609fe9b5f5360" + "id": "ba8f3772458e5efd" }, { "metadata": {}, @@ -2221,13 +2221,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "9923dbe3e6937f09" + "id": "42e0824c2b6e6f96" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "c6739d867595760d" + "id": "a4c783f2477c0aed" }, { "metadata": {}, @@ -2250,13 +2250,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "d6f51e6ddabb5426" + "id": "51ce29d7628bf1b8" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "8987f8795854b0d3" + "id": "6126083ca89e04bc" }, { "metadata": {}, @@ -2278,7 +2278,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "9b289597ccc85bb0" + "id": "28185ffcbd0606f3" }, { "metadata": {}, @@ -2299,7 +2299,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "2d9f445812e61010" + "id": "fee17876aac0991e" }, { "metadata": {}, @@ -2312,19 +2312,19 @@ "\n", "cli.get('/').text" ], - "id": "a0d50c343884f7" + "id": "8b41ad04a601516e" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "acbf8b7311f723fd" + "id": "e846f30998cb91ed" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "1ace95eeec5d7233" + "id": "563002946f61b59d" }, { "metadata": {}, @@ -2332,7 +2332,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "11278015a711f50" + "id": "8b5d1b7481cf821d" }, { "metadata": {}, @@ -2340,7 +2340,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "876cfcb58fff09be" + "id": "d56ba963ff739e47" }, { "metadata": {}, @@ -2359,7 +2359,7 @@ "\n", "foo.to(param='value')" ], - "id": "d223d27ab2024726" + "id": "f44f9f513b92e52e" }, { "metadata": {}, @@ -2375,7 +2375,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "e9ad23318cd58f72" + "id": "eda0d3ca130a9d3" }, { "metadata": {}, @@ -2388,7 +2388,7 @@ "\n", "foo.to(param='value')" ], - "id": "845c8d8cd8b678e2" + "id": "ee108bfb8959d071" }, { "metadata": {}, @@ -2404,7 +2404,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "ad7c328b17fdf13b" + "id": "1b1f6ee66ad7f95" }, { "metadata": {}, @@ -2416,7 +2416,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "840a147f46b96daa" + "id": "f96b6a88b07ebc3c" }, { "metadata": {}, @@ -2438,7 +2438,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "14d777ba36c4523e" + "id": "5069dc1e088ac131" }, { "metadata": {}, @@ -2453,7 +2453,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "89010e5a319a1ee" + "id": "c8e001bbbb8d7e6" }, { "metadata": {}, @@ -2467,7 +2467,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "ab04bc8df2f0627c" + "id": "440454b57f7ce9ae" }, { "metadata": {}, @@ -2480,7 +2480,7 @@ "\n", "cli.post('/hi').text" ], - "id": "87f365d396f69614" + "id": "2da404e86f7bb4f1" }, { "metadata": {}, @@ -2493,7 +2493,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "c2f7aff939dc9838" + "id": "9db1e91bd3587b82" }, { "metadata": {}, @@ -2516,7 +2516,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "67c8fd44c79ba121" + "id": "ac41993d2227dee8" }, { "metadata": {}, @@ -2529,7 +2529,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "11bf1da78fa14eed" + "id": "8933972cfc996310" }, { "metadata": {}, @@ -2541,7 +2541,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "bf550a863e622bb1" + "id": "f940e03bffe4c66" }, { "metadata": {}, @@ -2553,7 +2553,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "5c264fec32f081e0" + "id": "b04025027d08127e" }, { "metadata": {}, @@ -2565,7 +2565,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "cb14ea89414940fb" + "id": "23d8293222046546" }, { "metadata": {}, @@ -2577,7 +2577,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "561f7d3538fc1228" + "id": "9136c213aeade42c" }, { "metadata": {}, @@ -2589,7 +2589,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "418113e5fa132dd5" + "id": "89e89849ef44bbd9" }, { "metadata": {}, @@ -2602,7 +2602,7 @@ "\n", "cli.get('/link').text" ], - "id": "3909c1dd3e3392c4" + "id": "6b31ac100fa4e29a" }, { "metadata": {}, @@ -2619,7 +2619,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "90716a3aea939978" + "id": "eff23aa20278ed13" }, { "metadata": {}, @@ -2627,7 +2627,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "1966de3a3b6b0b51" + "id": "e9a57394568c59ef" }, { "metadata": {}, @@ -2657,7 +2657,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "338a57122f4d7622" + "id": "c93af6bf798b70fb" }, { "metadata": {}, @@ -2671,7 +2671,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "224151e95a080e42" + "id": "acba8d0b1b3d138b" }, { "metadata": {}, @@ -2683,7 +2683,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "ed0f5226db71a55b" + "id": "9fbd96cb6f2da337" }, { "metadata": {}, @@ -2694,7 +2694,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "f8abfe73aa7e665b" + "id": "dfd5682d7f9b074c" }, { "metadata": {}, @@ -2705,7 +2705,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "c9be8b9a13c7b" + "id": "648f2d8a096744bc" }, { "metadata": {}, @@ -2731,7 +2731,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "c09b199ed001f5c7" + "id": "b7fcad6b61e4df59" }, { "metadata": {}, @@ -2752,7 +2752,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "ec2f7f8a97792fd" + "id": "ec5e76ebe3e0fcdc" }, { "metadata": {}, @@ -2769,7 +2769,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "806bd5c5b8214027" + "id": "76db7d976388e3ec" }, { "metadata": {}, @@ -2777,7 +2777,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "41ad4d0dae24e431" + "id": "d8c35fedaf3130e0" }, { "metadata": {}, @@ -2785,7 +2785,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "270b73ac388beec3" + "id": "adb6052f90c3a166" }, { "metadata": {}, @@ -2820,7 +2820,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "5bb28848fd83279f" + "id": "3355a8e4230211c3" }, { "metadata": {}, @@ -2837,7 +2837,7 @@ "test_r(cli, '/hxtest2', '1', headers={'HX-Request':'1'})\n", "test_r(cli, '/app' , 'foo')" ], - "id": "46d098bb8867c394" + "id": "ca78bc1e62c94de1" }, { "metadata": {}, @@ -2849,7 +2849,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "cf42935badd43f5f" + "id": "eb9844fe5290f911" }, { "metadata": {}, @@ -2860,7 +2860,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "925cf36bab1b36bd" + "id": "2df9561ed1ec0085" }, { "metadata": {}, @@ -2871,7 +2871,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "57f7ee8411e7b167" + "id": "7833151d129fc2d7" }, { "metadata": {}, @@ -2882,7 +2882,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "8dd137ec345034ec" + "id": "bda0ae61b5aeaf0c" }, { "metadata": {}, @@ -2897,7 +2897,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "ec3b771e001ee9c7" + "id": "ee29c6f876467832" }, { "metadata": {}, @@ -3025,7 +3025,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "98e17f17ef0dc78a" + "id": "1d9465be7d5e9787" }, { "metadata": {}, @@ -3033,7 +3033,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "ec6e5345e542ab55" + "id": "bbedc4be762d75c9" }, { "metadata": {}, @@ -3048,7 +3048,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "2c61b6a60b3540d6" + "id": "37fd179f35c4e3c7" }, { "metadata": {}, @@ -3060,7 +3060,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "7ec54f9bf1c48765" + "id": "c32f163bf5a0c20e" }, { "metadata": {}, @@ -3082,7 +3082,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "aad9b9ce36863a63" + "id": "bc8e585ac460015a" }, { "metadata": {}, @@ -3102,7 +3102,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "34c2f446da12947a" + "id": "2cd966578e7cfd63" }, { "metadata": {}, @@ -3115,7 +3115,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "c92b006916962f75" + "id": "60526ff4cd5c9078" }, { "metadata": {}, @@ -3128,7 +3128,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "ec51f25bd3652cd1" + "id": "47b2906ce18cd5fd" }, { "metadata": {}, @@ -3139,7 +3139,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "d460e6dbdb8dd934" + "id": "8ab8b6f7ff4931ed" }, { "metadata": {}, @@ -3147,7 +3147,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "3d7dbed27003480a" + "id": "4eccbbe382abb504" }, { "metadata": {}, @@ -3163,7 +3163,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "864cf99f2c3ae7e4" + "id": "661b219db32534c3" }, { "metadata": {}, @@ -3179,7 +3179,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "ec071d4434f7b01e" + "id": "ad2613a30250c03f" }, { "metadata": {}, @@ -3196,7 +3196,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "f5690664c11111c3" + "id": "698f2aef7c379644" }, { "metadata": {}, @@ -3213,13 +3213,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "35906bc86879e702" + "id": "638daedb404c11de" }, { "metadata": {}, "cell_type": "markdown", "source": "Check that afterware like toasts are called correctly.", - "id": "715da9a9f886acdb" + "id": "4020afeba6268102" }, { "metadata": {}, @@ -3227,7 +3227,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "e097e6f2f34810bc" + "id": "e4462cee346d27d7" }, { "metadata": {}, @@ -3269,13 +3269,13 @@ "r = cli.get('/')\n", "assert tm in r.text" ], - "id": "a1b6328777990e33" + "id": "9049f79617eecaa6" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "71feed8c8eec723c" + "id": "18ccaa96af7b278b" }, { "metadata": {}, @@ -3283,7 +3283,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "37285b1359490d0a" + "id": "274f84a3d05dbe24" }, { "metadata": {}, @@ -3300,7 +3300,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "3911e2efa6eff2f7" + "id": "ed8a54535297c248" }, { "metadata": {}, @@ -3318,13 +3318,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "31ea8e937c9f18cb" + "id": "3b86251628c330d7" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "913d1b3cb80c4d0c" + "id": "6da261c96ffba230" }, { "metadata": {}, @@ -3346,13 +3346,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "42b7166381b2de31" + "id": "89bc4b12f61ed190" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "f827e72640085b2f" + "id": "8500b1d4bb69062f" }, { "metadata": {}, @@ -3368,13 +3368,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "63ab236a7e4c8b5e" + "id": "beda57687e9b441b" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "ecba79afd0c3b9e8" + "id": "1570578c8e6cde86" }, { "metadata": {}, @@ -3394,13 +3394,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "b834f3053cdaaff5" + "id": "463863760a263190" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "86b74f597dc5d2e9" + "id": "5aaa0a68c07da574" }, { "metadata": {}, @@ -3416,7 +3416,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "e64d3e485aa691fd" + "id": "f8e35000c65ba62e" }, { "metadata": {}, @@ -3432,13 +3432,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "d11b1608d51eb867" + "id": "7b9172d10408c9ed" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "78bcb538beffc3c1" + "id": "2e83b131027a820f" }, { "metadata": {}, @@ -3463,13 +3463,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "d9b6647ef0b1c120" + "id": "e35eda8d436c0d7a" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "a3ef3aafeed1c543" + "id": "14d4f5f2940dddc4" }, { "metadata": {}, @@ -3488,7 +3488,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "8d1ad3aa405f8952" + "id": "2c8672873fd6eff4" }, { "metadata": {}, @@ -3536,7 +3536,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "93fc46fe69d90aa2" + "id": "2bea13758101b485" }, { "metadata": {}, @@ -3544,7 +3544,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "b5410d5e8e309c99" + "id": "4e8eb2f750a573da" }, { "metadata": {}, @@ -3574,7 +3574,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "2bf8cf65a3dd1945" + "id": "621317c4291a63d3" }, { "metadata": {}, @@ -3585,7 +3585,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "8b3f8ed2df1bde26" + "id": "1ced9d3e7dc0374d" }, { "metadata": {}, @@ -3603,7 +3603,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "73718966d4787400" + "id": "d671af83ebfebba6" }, { "metadata": {}, @@ -3624,7 +3624,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "5acb58e120365823" + "id": "9f1a50df1b889859" }, { "metadata": {}, @@ -3637,7 +3637,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "5ac26b06271b614" + "id": "53ab520c3de086b6" }, { "metadata": {}, @@ -3645,7 +3645,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "26ba77d9d7360fe8" + "id": "ae401717e3e65a44" }, { "metadata": {}, @@ -3673,7 +3673,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "8be654b68b7e4fd6" + "id": "dc835f3656eaf5c1" }, { "metadata": {}, @@ -3684,7 +3684,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "71d836196c32426d" + "id": "791efcb188f97aaf" }, { "metadata": {}, @@ -3701,7 +3701,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "b4acf9b9badda242" + "id": "1ecad744ef2b3f5d" }, { "metadata": {}, @@ -3720,7 +3720,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "1a999d4fe38dc396" + "id": "8298ed03030b8235" }, { "metadata": {}, @@ -3733,7 +3733,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "a3201869573175c1" + "id": "6a11faba637e0598" }, { "metadata": {}, @@ -3744,7 +3744,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "1859d1c226783abe" + "id": "7520fe3f75ff0273" }, { "metadata": {}, @@ -3766,13 +3766,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "3f2e9dfaf07906ad" + "id": "9d3e5f1dfc4454a5" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "cefabdfd3804ce65" + "id": "cb33740f9931b567" }, { "metadata": {}, @@ -3780,7 +3780,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "f4f134d385ba979c" + "id": "be443700247fb92e" }, { "metadata": {}, @@ -3805,7 +3805,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "8db47f8de086a404" + "id": "5b54ca7b445b95d0" }, { "metadata": {}, @@ -3823,7 +3823,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "b83427dfe5a39ae8" + "id": "ded4799ac019dbe7" }, { "metadata": {}, @@ -3837,7 +3837,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "d18e6ecc94583abc" + "id": "b15d17a5c6de19c2" }, { "metadata": {}, @@ -3857,7 +3857,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "88b7340a78a5e97c" + "id": "da7cf380b731da02" }, { "metadata": {}, @@ -3872,7 +3872,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "dd357f05f363eddb" + "id": "ac5723265cb90da3" }, { "metadata": {}, @@ -3883,7 +3883,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "7e6039c9c6cc9e" + "id": "261b16cb45660e65" }, { "metadata": {}, @@ -3898,7 +3898,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "4433cd8119244424" + "id": "baea241aa4415b80" }, { "metadata": {}, @@ -3909,7 +3909,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "2f661b66f3e94173" + "id": "2b1d76f47e6c504" }, { "metadata": {}, @@ -3925,7 +3925,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "7dd76a7c38bf4ec0" + "id": "cbdb4ced4c5f2fd" }, { "metadata": {}, @@ -3941,13 +3941,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "341dff8d4f8feb92" + "id": "46c42cf52af0cf6" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "70c4f465f1a1e4d9" + "id": "2f02fd044b7e6f7c" }, { "metadata": {}, @@ -3962,7 +3962,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "78397e0f041db120" + "id": "a5aca5ab0053e480" }, { "metadata": {}, @@ -3981,13 +3981,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "91e627079c83160d" + "id": "374016a0f9b1fd8f" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "bc9f863ad8101d60" + "id": "2ef4d5521decc854" }, { "metadata": {}, @@ -4007,7 +4007,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "35c4396c812bdbbb" + "id": "486e33ec58e33f6f" }, { "metadata": {}, @@ -4019,7 +4019,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "e77d8aa644feefcd" + "id": "8c63143a26a88d2b" }, { "metadata": {}, @@ -4038,7 +4038,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "97d0447b3fe8d090" + "id": "3fe02cd26e9372c8" }, { "metadata": {}, @@ -4068,19 +4068,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "d9d0b14c50a4ee20" + "id": "32741e75dc3e7738" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "170d28cff6e4799a" + "id": "d9b63b093a275569" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "da98f752664d3c67" + "id": "17228f0b1e481002" }, { "metadata": {}, @@ -4096,13 +4096,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "ef603fee8f8e74f9" + "id": "4fd55d8c8aa07481" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "c9e190baca5d582c" + "id": "bb0ee26d1667e259" }, { "metadata": {}, @@ -4114,13 +4114,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "af10b39dcbaf6fcf" + "id": "d628bc42184b7313" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "dc4f32eee6a84a4" + "id": "ccd09b2154990ebb" }, { "metadata": {}, @@ -4132,7 +4132,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "9b9ac10381a145ae" + "id": "32a8e802e0ddc7d3" }, { "metadata": {}, @@ -4148,7 +4148,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "7a5ebb738f694d75" + "id": "204806dce2db3c7" }, { "metadata": {}, @@ -4171,7 +4171,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "41b3a970fb0d5b7c" + "id": "d6f4e6547207c467" }, { "metadata": {}, @@ -4191,13 +4191,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "4bd5d1f492918b6a" + "id": "58fd0fb735784994" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "6df76d2a9608cbdc" + "id": "255ca8560b1f7a72" }, { "metadata": {}, @@ -4218,13 +4218,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "d08eb5f58af30580" + "id": "4ee096c361ce0e8e" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "99c9fd088ff2c315" + "id": "bafb0ae46c2c25db" }, { "metadata": {}, @@ -4238,7 +4238,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "a5502eff873e0492" + "id": "f3eea23bbaa3b111" }, { "metadata": {}, @@ -4255,7 +4255,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "1536be70e3244b35" + "id": "f5f8db320d2f93f1" }, { "metadata": {}, @@ -4271,7 +4271,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "d4bca91cd874fc15" + "id": "15621d1cbcc6cd5d" }, { "metadata": {}, @@ -4288,7 +4288,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "e20e7c963bf0e8d3" + "id": "58849b9e0df97f10" }, { "metadata": {}, @@ -4303,7 +4303,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "eabef5efb84fbbce" + "id": "cdd6ad44c38a4b4f" }, { "metadata": {}, @@ -4317,7 +4317,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "7b02ac1afad917b7" + "id": "d4e9bf553babebff" }, { "metadata": {}, @@ -4337,7 +4337,7 @@ " app._send = send\n", " return send" ], - "id": "3214911e487f8536" + "id": "e3bc86255a535e1c" }, { "metadata": {}, @@ -4355,7 +4355,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "9b073cb96670c884" + "id": "47f0ccef326ca8bb" }, { "metadata": {}, @@ -4375,7 +4375,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "d9fc5093f40be209" + "id": "3c34070a261296c0" }, { "metadata": {}, @@ -4391,7 +4391,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "da949a3f6dd16b6c" + "id": "abd39a12bd55fe96" }, { "metadata": {}, @@ -4414,7 +4414,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "a631620ade78cb51" + "id": "51b3a040004e2391" }, { "metadata": {}, @@ -4425,13 +4425,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "9a572b597b4142d6" + "id": "179a77ec36787faa" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "87aff436308386ab" + "id": "b41ade09c8f11e7" }, { "metadata": {}, @@ -4443,7 +4443,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "8e4a52eca26ad422" + "id": "b2495577cf248660" } ], "metadata": { From 62a665d2fedcb23f8f4ecebcdcd665a100cedb21 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Wed, 21 Oct 2026 11:48:54 -0300 Subject: [PATCH 09/22] refactor: improve request parameter handling and update documentation --- fasthtml/core.py | 228 ++-- nbs/api/00_core.ipynb | 2366 ++++++++++++++++++++++------------------- 2 files changed, 1359 insertions(+), 1235 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 894096cb..2278ddce 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -18,7 +18,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. -Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: +Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. To read a different request name, use `Annotated[T, Alias('name')]`. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: req, ws the Request / WebSocket connection sess session dict @@ -54,7 +54,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] -# %% ../nbs/api/00_core.ipynb #23503b9e +# %% ../nbs/api/00_core.ipynb #b7a14a169b3297a0 import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess from uuid import uuid5, NAMESPACE_URL @@ -82,23 +82,23 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from .starlette import * from python_multipart.exceptions import MultipartParseError -# %% ../nbs/api/00_core.ipynb #19d3f2a7 +# %% ../nbs/api/00_core.ipynb #1585be45cd347d8d def _params(f): return signature_ex(f, True).parameters empty = Parameter.empty -# %% ../nbs/api/00_core.ipynb #e68a76c9 +# %% ../nbs/api/00_core.ipynb #aecb7676f7cc2369 def parsed_date(s:str): "Convert `s` to a datetime" return dtparse.parse(s) -# %% ../nbs/api/00_core.ipynb #7c820373 +# %% ../nbs/api/00_core.ipynb #8615d8ef40e50851 def snake2hyphens(s:str): "Convert `s` from snake case to hyphenated and capitalised" s = snake2camel(s) return camel2words(s, '-') -# %% ../nbs/api/00_core.ipynb #25f3b8f8 +# %% ../nbs/api/00_core.ipynb #81ad0c31c5e60260 htmx_hdrs = dict( boosted="HX-Boosted", current_url="HX-Current-URL", @@ -123,13 +123,13 @@ def _get_htmx(h): res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()} return HtmxHeaders(**res) -# %% ../nbs/api/00_core.ipynb #1d53e8e7 +# %% ../nbs/api/00_core.ipynb #49368755a70898a0 def _mk_list(t, v): return [t(o) for o in listify(v)] -# %% ../nbs/api/00_core.ipynb #5ab74473 +# %% ../nbs/api/00_core.ipynb #58685ffc8373ba6f fh_cfg = AttrDict(indent=True) -# %% ../nbs/api/00_core.ipynb #88d12e68 +# %% ../nbs/api/00_core.ipynb #287965806fac1332 _special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'} def _check_anno(arg, anno): @@ -137,7 +137,7 @@ def _check_anno(arg, anno): if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f"`{arg}` has no type annotation and is not a recognised special name, so is ignored." if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead)." -# %% ../nbs/api/00_core.ipynb #0afb520c +# %% ../nbs/api/00_core.ipynb #e0ed6b4ddd1014c1 def _fix_anno(t, o): "Create appropriate callable type for casting a `str` to type `t` (or first type in `t` if union)" origin = get_origin(t) @@ -150,7 +150,7 @@ def _fix_anno(t, o): if not isinstance(o, (str,list,tuple)): return o return res(o[-1]) if isinstance(o,(list,tuple)) else res(o) -# %% ../nbs/api/00_core.ipynb #c58ccadb +# %% ../nbs/api/00_core.ipynb #3b51bbad3c65426 def _form_arg(k, v, d): "Get type by accessing key `k` from `d`, and use to cast `v`" if v is None: return @@ -160,35 +160,35 @@ def _form_arg(k, v, d): if not anno: return v return _fix_anno(anno, v) -# %% ../nbs/api/00_core.ipynb #5fc04751 +# %% ../nbs/api/00_core.ipynb #55342e74abb8a521 @dataclass class HttpHeader: "A response header as a k,v pair" k:str;v:str -# %% ../nbs/api/00_core.ipynb #94e18161 +# %% ../nbs/api/00_core.ipynb #4e1a41ff0d2006f8 def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title() htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None, reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None) -# %% ../nbs/api/00_core.ipynb #f6a2e62e +# %% ../nbs/api/00_core.ipynb #1bcf3e6bbeb82969 @use_kwargs_dict(**htmx_resps) def HtmxResponseHeaders(**kwargs): "HTMX response headers" res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items()) return res[0] if len(res)==1 else res -# %% ../nbs/api/00_core.ipynb #56cc589f +# %% ../nbs/api/00_core.ipynb #9efb05e73d5f5947 def _annotations(anno): "Same as `get_annotations`, but also works on namedtuples" if is_namedtuple(anno): return {o:str for o in anno._fields} return get_annotations(anno) -# %% ../nbs/api/00_core.ipynb #cb4ed4aa +# %% ../nbs/api/00_core.ipynb #e5828b2e7682c383 def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno) -# %% ../nbs/api/00_core.ipynb #ffdde66f +# %% ../nbs/api/00_core.ipynb #a3058a8b3943fd8d def _formitem(form, k): "Return item `k`, accepting hyphens for underscores and unwrapping single-item form lists" if k not in form: k = {o.replace('-', '_'):o for o in form}.get(k, k) @@ -196,16 +196,16 @@ def _formitem(form, k): o = form.getlist(k) return o[0] if len(o) == 1 else o if o else None -# %% ../nbs/api/00_core.ipynb #5fe74444 +# %% ../nbs/api/00_core.ipynb #3c3233e509f6970e def form2dict(form: FormData) -> dict: "Convert starlette form data to a dict" if isinstance(form, dict): return form return {k: _formitem(form, k) for k in form} -# %% ../nbs/api/00_core.ipynb #8899bc0a +# %% ../nbs/api/00_core.ipynb #da31ece677242cc6 DEF_MAXPART = 100*1024*1024 -# %% ../nbs/api/00_core.ipynb #42c9cea0 +# %% ../nbs/api/00_core.ipynb #73947311e8867ddf async def parse_form(req: Request) -> FormData: "Starlette errors on empty multipart/json forms and 500s on malformed multipart, so this handles those situations" ctype = req.headers.get("Content-Type", "") @@ -224,7 +224,7 @@ async def parse_form(req: Request) -> FormData: return res return await req.form(max_part_size=maxpart) -# %% ../nbs/api/00_core.ipynb #0caedd04 +# %% ../nbs/api/00_core.ipynb #e80f45b5f2d1c328 async def _from_body(conn, p, data): "Create an instance of the annotated type from pre-parsed `data`" anno = p.annotation @@ -240,7 +240,7 @@ async def _from_body(conn, p, data): cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d} return anno(**cargs) -# %% ../nbs/api/00_core.ipynb #88b6da3f +# %% ../nbs/api/00_core.ipynb #eb20d63ddb8c94ef class ApiReturn: "Request param: call with browser and API responses; returns kwargs as JSON if the client accepts `application/json`, else `norm`" @classmethod @@ -249,7 +249,7 @@ def __init__(self, isapi=False): self.isapi = isapi def __call__(self, norm=None, **kw): return kw if self.isapi else norm def __bool__(self): return bool(self.isapi) -# %% ../nbs/api/00_core.ipynb #7cc39ba9 +# %% ../nbs/api/00_core.ipynb #61ac79f78f6492d6 class JSONResponse(JSONResponseOrig): "Same as starlette's version, but auto-stringifies non serializable types" def render(self, content:Any)->bytes: @@ -258,7 +258,7 @@ def _default(o): return list(o) if is_listy(o) else str(o) return res.encode("utf-8") -# %% ../nbs/api/00_core.ipynb #5fa96e3a +# %% ../nbs/api/00_core.ipynb #4d46f096ae3f0e05 @dataclass(frozen=True) class Alias: "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" @@ -283,28 +283,28 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if _is_body(anno): if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) return await _from_body(conn, p, data) - if (msg := _check_anno(arg, anno)): return warn(msg) + if (msg := _check_anno(key, anno)): return warn(msg) # Special param names with no annotations if anno is empty: - if arg.lower()=='ws' or 'request'.startswith(arg.lower()): return conn - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) - if arg.lower()=='scope': return conn.scope - if arg.lower()=='data': return data - if arg.lower()=='htmx': return _get_htmx(hdrs) - if arg.lower()=='app': return conn.scope['app'] - if arg.lower()=='state': return conn.scope['app'].state - if arg.lower()=='auth': return conn.scope.get('auth', None) - if arg.lower()=='send': + if key.lower()=='ws' or 'request'.startswith(key.lower()): return conn + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) + if key.lower()=='scope': return conn.scope + if key.lower()=='data': return data + if key.lower()=='htmx': return _get_htmx(hdrs) + if key.lower()=='app': return conn.scope['app'] + if key.lower()=='state': return conn.scope['app'].state + if key.lower()=='auth': return conn.scope.get('auth', None) + if key.lower()=='send': assert not isinstance(conn, Request), "`send` requires a websocket, not a `Request`" return partial(_send_ws, conn) - if arg.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json') - if arg.lower()=='body': return (await conn.body()).decode() - if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) + if key.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json') + if key.lower()=='body': return (await conn.body()).decode() + if key.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, key.lower()) return None # Not a special name or a special annotation res = conn.path_params.get(key, None) if res in (empty,None): res = conn.cookies.get(key, None) - if res in (empty,None): res = hdrs.get(alias or snake2hyphens(key), None) + if res in (empty,None): res = hdrs.get(snake2hyphens(key), None) if res in (empty,None): res = _formitem(conn.query_params, key) if res in (empty,None): res = _formitem(data, key) if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` @@ -318,17 +318,17 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if isinstance(conn, Request): raise HTTPException(404, f"{conn.url.path}: {e}") from None raise -# %% ../nbs/api/00_core.ipynb #bf42edad +# %% ../nbs/api/00_core.ipynb #5849a37703c71c4b async def _find_ps(conn, data, hdrs, params): if conn.query_params: data |= dict(conn.query_params) return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()} -# %% ../nbs/api/00_core.ipynb #090f1f0f +# %% ../nbs/api/00_core.ipynb #b4a0893dd0cac34a async def _wrap_req(req, params): data = form2dict(await parse_form(req)) return await _find_ps(req, data, req.headers, params) -# %% ../nbs/api/00_core.ipynb #7a661bfa +# %% ../nbs/api/00_core.ipynb #4b264f3ec9a80e3a def flat_xt(lst): "Flatten lists" result = [] @@ -338,23 +338,23 @@ def flat_xt(lst): else: result.append(item) return tuple(result) -# %% ../nbs/api/00_core.ipynb #aacff5ac +# %% ../nbs/api/00_core.ipynb #4b57d3d58af69e51 class Beforeware: "Wrap a `before` function with `skip` path patterns that bypass it" def __init__(self, f, skip=None): self.f,self.skip = f,skip or [] def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})' -# %% ../nbs/api/00_core.ipynb #78c3c357 +# %% ../nbs/api/00_core.ipynb #de37dcd7b5641749 async def _handle(f, /, *args, **kwargs): return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs) -# %% ../nbs/api/00_core.ipynb #ad0f0e87 +# %% ../nbs/api/00_core.ipynb #761597782f7f9df6 async def _wrap_ws(ws, data, params): raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers` hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None}) return await _find_ps(ws, data, hdrs, params) -# %% ../nbs/api/00_core.ipynb #dcc15129 +# %% ../nbs/api/00_core.ipynb #7a3be2b2f30770d7 async def _send_ws(ws, resp): if not resp: return res = to_xml(resp, indent=fh_cfg.indent) @@ -382,12 +382,12 @@ async def _recv(self, ws, data): await _generic_handler(recv, ws, data) cls.on_receive = _recv return cls -# %% ../nbs/api/00_core.ipynb #5b0e7677 +# %% ../nbs/api/00_core.ipynb #ddc64ff604c831b8 def EventStream(s): "Create a text/event-stream response from `s`" return StreamingResponse(s, media_type="text/event-stream") -# %% ../nbs/api/00_core.ipynb #0dd0a414 +# %% ../nbs/api/00_core.ipynb #2e11648d17d6d6f5 def signal_shutdown(): "Return an `asyncio.Event` set when uvicorn receives a shutdown signal" from uvicorn.main import Server @@ -399,21 +399,21 @@ def handle_exit(self:Server, *args, **kwargs): self._orig_handle_exit(*args, **kwargs) return event -# %% ../nbs/api/00_core.ipynb #03f4c639 +# %% ../nbs/api/00_core.ipynb #f660c44ff209b90c def uri(_arg, **kwargs): "Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`" return f"{quote(_arg)}/{urlencode(kwargs, doseq=True)}" -# %% ../nbs/api/00_core.ipynb #bdb2ac14 +# %% ../nbs/api/00_core.ipynb #3e45f287f5af27c5 def decode_uri(s): "Decode a URI created by `uri()` back into argument and keyword dict" arg,_,kw = s.partition('/') return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()} -# %% ../nbs/api/00_core.ipynb #b80ce139 +# %% ../nbs/api/00_core.ipynb #580afa399f3bd333 from starlette.convertors import StringConvertor -# %% ../nbs/api/00_core.ipynb #a27adc1e +# %% ../nbs/api/00_core.ipynb #2bb9a1ff5768ba68 StringConvertor.regex = "[^/]*" # `+` replaced with `*` @patch @@ -423,13 +423,13 @@ def to_string(self:StringConvertor, value: str) -> str: # assert value, "Must not be empty" # line removed due to errors return value -# %% ../nbs/api/00_core.ipynb #46614165 +# %% ../nbs/api/00_core.ipynb #d99b78e3a99c3ec0 @patch def url_path_for(self:HTTPConnection, name: str, **path_params): lp = self.scope['app'].url_path_for(name, **path_params) return URLPath(f"{self.scope['root_path']}{lp}", lp.protocol, lp.host) -# %% ../nbs/api/00_core.ipynb #c1707d59 +# %% ../nbs/api/00_core.ipynb #c4d78aa8d576e6cc _verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href') def _url_for(req, t): @@ -455,10 +455,10 @@ def _to_xml(req, resp, indent): _find_targets(req, resp) return to_xml(resp, indent=indent) -# %% ../nbs/api/00_core.ipynb #f1e3ed2d +# %% ../nbs/api/00_core.ipynb #f2f473dc3aa8e232 _iter_typs = (tuple,list,map,filter,range,types.GeneratorType) -# %% ../nbs/api/00_core.ipynb #a407dc0e +# %% ../nbs/api/00_core.ipynb #ce31ff717114b811 def flat_tuple(o): "Flatten nested iterables into a single tuple" result = [] @@ -469,12 +469,12 @@ def flat_tuple(o): else: result.append(item) return tuple(result) -# %% ../nbs/api/00_core.ipynb #d36402e6 +# %% ../nbs/api/00_core.ipynb #aaf0c02fc0142c20 def noop_body(c, req): "Default Body wrap function which just returns the content" return c -# %% ../nbs/api/00_core.ipynb #7f49728d +# %% ../nbs/api/00_core.ipynb #6df817363734849b def respond(req, heads, bdy): "Default FT response creation function" body_wrap = getattr(req, 'body_wrap', noop_body) @@ -483,13 +483,13 @@ def respond(req, heads, bdy): body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw) return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw) -# %% ../nbs/api/00_core.ipynb #b2007479 +# %% ../nbs/api/00_core.ipynb #2f96c17575a317a def is_full_page(req, resp): "Check if response should be rendered as full page or fragment" if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers -# %% ../nbs/api/00_core.ipynb #358badec +# %% ../nbs/api/00_core.ipynb #99a2f618ca8e26e9 def _part_resp(req, resp): "Partition response into HTTP headers, background tasks, and content" resp = flat_tuple(resp) @@ -506,7 +506,7 @@ def _part_resp(req, resp): if len(resp)==1: resp = resp[0] return resp,kw -# %% ../nbs/api/00_core.ipynb #da449a7b +# %% ../nbs/api/00_core.ipynb #d18dc765f6c377c3 def _canonical(req): if not req.app.canonical: return [] url = str(getattr(req, 'canonical', req.url)).replace('http://', 'https://', 1) @@ -522,12 +522,12 @@ def _xt_cts(req, resp): resp = respond(req, [*heads, *title, *_canonical(req)], bdy) return _to_xml(req, resp, indent=fh_cfg.indent) -# %% ../nbs/api/00_core.ipynb #775fb66b +# %% ../nbs/api/00_core.ipynb #5afa1b8942912373 def _is_ft_resp(resp): "Check if response is a FastTag-compatible type" return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__') -# %% ../nbs/api/00_core.ipynb #968d9245 +# %% ../nbs/api/00_core.ipynb #67c986c99e7a6231 def _resp(req, resp, cls=empty, status_code=200): "Create appropriate HTTP response from request and response data" if resp is None: resp='' @@ -547,7 +547,7 @@ def _resp(req, resp, cls=empty, status_code=200): cls = HTMLResponse return cls(resp, status_code=status_code, **kw) -# %% ../nbs/api/00_core.ipynb #eac44461 +# %% ../nbs/api/00_core.ipynb #15e507d2acd3232e class Redirect: "Use HTMX or Starlette RedirectResponse as required to redirect to `loc`" def __init__(self, loc): self.loc = loc @@ -555,13 +555,13 @@ def __response__(self, req): if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc) return RedirectResponse(self.loc, status_code=303) -# %% ../nbs/api/00_core.ipynb #1ba52822 +# %% ../nbs/api/00_core.ipynb #92a6fdb45add90f0 async def _wrap_call(f, req, params): "Wrap function call with request parameter injection" wreq = await _wrap_req(req, params) return await _handle(f, **wreq) -# %% ../nbs/api/00_core.ipynb #b0d1cbbf +# %% ../nbs/api/00_core.ipynb #49c3d12b947fa377 # chkstyle: skip htmx_exts = { "morph": "https://cdn.jsdelivr.net/npm/idiomorph@0.7.3/dist/idiomorph-ext.min.js", @@ -581,7 +581,7 @@ async def _wrap_call(f, req, params): "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js" } -# %% ../nbs/api/00_core.ipynb #60cb52ea +# %% ../nbs/api/00_core.ipynb #b31e24130b390101 htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") @@ -590,7 +590,7 @@ async def _wrap_call(f, req, params): viewport = Meta(name="viewport", content="width=device-width, initial-scale=1, viewport-fit=cover") charset = Meta(charset="utf-8") -# %% ../nbs/api/00_core.ipynb #8bd78eeb +# %% ../nbs/api/00_core.ipynb #af5fa00cfaef7a8b def get_key(key=None, fname='.sesskey'): "Get session key from `key` param or read/create from file `fname`" if key: return key @@ -600,12 +600,12 @@ def get_key(key=None, fname='.sesskey'): fname.write_text(key) return key -# %% ../nbs/api/00_core.ipynb #042666e2 +# %% ../nbs/api/00_core.ipynb #17ff3158cc805754 def _list(o): "Wrap non-list item in a list, returning empty list if None" return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o] -# %% ../nbs/api/00_core.ipynb #d276fc71 +# %% ../nbs/api/00_core.ipynb #d2a4061192d883e8 def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap): "Wrap exception handler with FastHTML request processing" async def _f(req, exc): @@ -615,7 +615,7 @@ async def _f(req, exc): return _resp(req, res, status_code=status_code) return _f -# %% ../nbs/api/00_core.ipynb #bc323fd4 +# %% ../nbs/api/00_core.ipynb #f0ddf2219c879e3b def qp(p:str, **kw) -> str: "Add parameters kw to path p" def _sub(m): @@ -627,7 +627,7 @@ def _sub(m): # encode query params return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '') -# %% ../nbs/api/00_core.ipynb #f86690c4 +# %% ../nbs/api/00_core.ipynb #8add43015a5b4c0c def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] @@ -640,7 +640,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs return [charset, viewport] + hdrs -# %% ../nbs/api/00_core.ipynb #2c5285ae +# %% ../nbs/api/00_core.ipynb #54f3bb368193da5c cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*']) iframe_scr = Script(NotStr(""" @@ -666,7 +666,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): """)) -# %% ../nbs/api/00_core.ipynb #17ced9a3 +# %% ../nbs/api/00_core.ipynb #876c9c8cb63f2cf2 class _LifespanCtx: def __init__(self, gen): self.gen = gen async def __aenter__(self): return await anext(self.gen) @@ -693,7 +693,7 @@ async def _run(self, app): def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f) -# %% ../nbs/api/00_core.ipynb #3327a1e9 +# %% ../nbs/api/00_core.ipynb #d8d78a64be4bf27 class FastHTML(Starlette): "An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments" def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None, @@ -732,7 +732,7 @@ def _not_found(req, exc): return Response('404 Not Found', status_code=404) def on_event(self, event_type): return self.lifespan.on_event(event_type) -# %% ../nbs/api/00_core.ipynb #dce68049 +# %% ../nbs/api/00_core.ipynb #b000f60adf333122 class HostRoute(Route): "Route with optional host-header constraint using Starlette's {param} pattern syntax" def __init__(self, path, endpoint, *, host=None, **kwargs): @@ -750,7 +750,7 @@ def __repr__(self) -> str: methods = sorted(self.methods or []) return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})" -# %% ../nbs/api/00_core.ipynb #e0accf76 +# %% ../nbs/api/00_core.ipynb #a1583c0bcb0420f2 @patch def add_route(self:FastHTML, route): "Add or replace a route in the FastHTML app" @@ -762,10 +762,10 @@ def add_route(self:FastHTML, route): self.router.routes.sort(key=lambda r: not getattr(r, 'host', None)) -# %% ../nbs/api/00_core.ipynb #246bd8d1 +# %% ../nbs/api/00_core.ipynb #4917c9f35ccd9f96 all_meths = 'get post put delete patch head trace options'.split() -# %% ../nbs/api/00_core.ipynb #26b147ba +# %% ../nbs/api/00_core.ipynb #46ca67a58ad666b9 @patch def _endp(self:FastHTML, f, body_wrap, before:Optional[Callable|tuple]=None): "Create endpoint wrapper with before/after middleware processing" @@ -794,7 +794,7 @@ async def _f(req): return _resp(req, resp, sig.return_annotation) return _f -# %% ../nbs/api/00_core.ipynb #3818575c +# %% ../nbs/api/00_core.ipynb #a0744b04d81228d6 @patch def _add_ws(self:FastHTML, func, path, conn, disconn, name, middleware): "Add websocket route to FastHTML app" @@ -804,20 +804,20 @@ def _add_ws(self:FastHTML, func, path, conn, disconn, name, middleware): self.add_route(route) return func -# %% ../nbs/api/00_core.ipynb #669e76eb +# %% ../nbs/api/00_core.ipynb #2fc600349ae25291 @patch def ws(self:FastHTML, path:str, conn=None, disconn=None, name=None, middleware=None): "Add a websocket route at `path`" def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware) return f -# %% ../nbs/api/00_core.ipynb #j6ete5u68fo +# %% ../nbs/api/00_core.ipynb #17f6217b9e528764 @patch def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None): "Add a websocket route at `path` (Starlette-compatible API)" return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware) -# %% ../nbs/api/00_core.ipynb #919618c3 +# %% ../nbs/api/00_core.ipynb #48e0a2670e869b6b def _mk_locfunc(f, p, app=None): "Create a location function wrapper with route path and to() method" class _lf: @@ -831,12 +831,12 @@ def __str__(self): return p return _lf() -# %% ../nbs/api/00_core.ipynb #d2ecc738 +# %% ../nbs/api/00_core.ipynb #a0c09d4bddc479a def nested_name(f): "Get name of function `f` using '_' to join nested function names" return f.__qualname__.replace('..', '_') -# %% ../nbs/api/00_core.ipynb #daafe4fc +# %% ../nbs/api/00_core.ipynb #8f0115f7adff7835 @patch def _add_routes(self:FastHTML, cls, path, methods, name, include_in_schema, body_wrap, host=None, before:Optional[Callable|tuple]=None): "Add HTTP routes from methods on endpoint class `cls`" @@ -848,7 +848,7 @@ def _add_routes(self:FastHTML, cls, path, methods, name, include_in_schema, body if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before) return lf -# %% ../nbs/api/00_core.ipynb #3710e48b +# %% ../nbs/api/00_core.ipynb #329e78f5bbc6ded1 def _route_pn(func, path, name): "Infer route name/function name/path from an endpoint or endpoint class" fn = nested_name(func) @@ -873,7 +873,7 @@ def _add_route(self:FastHTML, func, path, methods, name, include_in_schema, body lf.__routename__ = n return lf -# %% ../nbs/api/00_core.ipynb #f5cb2c2b +# %% ../nbs/api/00_core.ipynb #ebf0ff44a915aa0c @patch def route(self:FastHTML, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=None, host=None, before:Optional[Callable|tuple]=None): "Add a route at `path`" @@ -883,14 +883,14 @@ def f(func): for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o)) -# %% ../nbs/api/00_core.ipynb #35c35a96 +# %% ../nbs/api/00_core.ipynb #94d6f8f81d93aa5e @patch def set_lifespan(self:FastHTML, value): "Set the lifespan context manager for the FastHTML app" if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value) self.router.lifespan_context = value -# %% ../nbs/api/00_core.ipynb #3a348474 +# %% ../nbs/api/00_core.ipynb #b5e55224f5a20d18 def serve( appname=None, # Name of the module app='app', # App instance to be served @@ -916,14 +916,14 @@ def serve( print('Link: '+ S.light_red.bold(link)) run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs) -# %% ../nbs/api/00_core.ipynb #91b08e9a +# %% ../nbs/api/00_core.ipynb #ddc667c24b6df7dc def _spawn(n): "Run this script `n` times over, numbering each copy in `FH_PROCNUM`, and wait for them all" ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)] signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps]) for p in ps: p.wait() -# %% ../nbs/api/00_core.ipynb #c5220a0e +# %% ../nbs/api/00_core.ipynb #78dc3bffc42b5d1 async def _wait_disconnect(req): while (await req.receive())["type"] != "http.disconnect": pass @@ -938,7 +938,7 @@ async def until_disconnect(req, coro): watch.cancel() if not work.done(): work.cancel() -# %% ../nbs/api/00_core.ipynb #c5721d76 +# %% ../nbs/api/00_core.ipynb #3ddda418760bb472 def cancel_on_disconnect( f=None, # Async route handler to wrap *, @@ -952,7 +952,7 @@ async def _f(__req: Request, *args, **kw): return await (asyncio.wait_for(c, timeout) if timeout is not None else c) return splice_sig(_f, f) -# %% ../nbs/api/00_core.ipynb #8121968a +# %% ../nbs/api/00_core.ipynb #33e9385190411631 class Client: "A simple httpx ASGI client that doesn't require `async`" def __init__(self, app, url="http://testserver"): @@ -965,7 +965,7 @@ async def _request(): return await self.cli.request(method, url, **kwargs) for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o)) -# %% ../nbs/api/00_core.ipynb #d5223a9a +# %% ../nbs/api/00_core.ipynb #d00984c329331d4 class RouteFuncs: "Attr-access store of named route functions (HTTP verb names excluded)" def __init__(self): super().__setattr__('_funcs', {}) @@ -976,7 +976,7 @@ def __getattr__(self, name): except KeyError: raise AttributeError(f"No route named {name} found in route functions") def __dir__(self): return list(self._funcs.keys()) -# %% ../nbs/api/00_core.ipynb #0f08cb5e +# %% ../nbs/api/00_core.ipynb #55ff40c3aa444235 class APIRouter: "Add routes to an app" def __init__(self, prefix:str|None=None, body_wrap=noop_body): @@ -1016,10 +1016,10 @@ def ws(self, path:str, conn=None, disconn=None, name=None, middleware=None): def f(func=noop): return self.wss.append((func, f"{self.prefix}{path}", conn, disconn, name, middleware)) return f -# %% ../nbs/api/00_core.ipynb #259a0f53 +# %% ../nbs/api/00_core.ipynb #1ea2020dd58c30df for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o)) -# %% ../nbs/api/00_core.ipynb #e518de83 +# %% ../nbs/api/00_core.ipynb #e645895044c0f961 def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None, secure=False, httponly=False, samesite="lax",): "Create a 'set-cookie' `HttpHeader`" cookie = cookies.SimpleCookie() @@ -1036,13 +1036,13 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None cookie_val = cookie.output(header="").strip() return HttpHeader("set-cookie", cookie_val) -# %% ../nbs/api/00_core.ipynb #8816f277 +# %% ../nbs/api/00_core.ipynb #c094b01b787efa18 def reg_re_param(m, s): "Register a Starlette URL convertor named `m` matching regex `s`" cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s) register_url_convertor(m, cls()) -# %% ../nbs/api/00_core.ipynb #e9535978 +# %% ../nbs/api/00_core.ipynb #6747ee1f8fa10ddb # Starlette doesn't have the '?', so it chomps the whole remaining URL reg_re_param("path", ".*?") _static_exts = "ico gif jpg jpeg webm css js woff png svg mp4 webp ttf otf eot woff2 txt html map pdf zip tgz gz csv mp3 wav ogg flac aac doc docx xls xlsx ppt pptx epub mobi bmp tiff avi mov wmv mkv xml yaml yml rar 7z tar bz2 htm xhtml apk dmg exe msi swf iso".split() @@ -1054,14 +1054,14 @@ def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static') @self.get(f"{prefix}{{fname:path}}.{{ext:{exts}}}") async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}') -# %% ../nbs/api/00_core.ipynb #b31de65a +# %% ../nbs/api/00_core.ipynb #f821491210017e95 @patch def static_route(self:FastHTML, ext='', prefix='/', static_path='.'): "Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')" @self.get(f"{prefix}{{fname:path}}{ext}") async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}') -# %% ../nbs/api/00_core.ipynb #f63b7a03 +# %% ../nbs/api/00_core.ipynb #392f9abb139bb38f class StaticNoCache(StaticFiles): "StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use" def file_response(self, *args, **kwargs): @@ -1069,7 +1069,7 @@ def file_response(self, *args, **kwargs): resp.headers.setdefault("Cache-Control", "no-cache") return resp -# %% ../nbs/api/00_core.ipynb #74801f94 +# %% ../nbs/api/00_core.ipynb #a8094de91a8f6335 class StaticImmutable(StaticFiles): "Like `StaticNoCache`, but marks responses as cacheable forever: for versioned URLs such as `vurl` links or content-hashed filenames" def file_response(self, *args, **kwargs): @@ -1077,7 +1077,7 @@ def file_response(self, *args, **kwargs): resp.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") return resp -# %% ../nbs/api/00_core.ipynb #e38fd6c7 +# %% ../nbs/api/00_core.ipynb #7fffdd2e178e66d9 @flexicache(mtime_policy(arg=0)) def _vhash(p): return hashlib.blake2b(p.read_bytes(), digest_size=4).hexdigest() @@ -1089,11 +1089,11 @@ def vurl(path, root='.'): return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}')) -# %% ../nbs/api/00_core.ipynb #7189daf8 +# %% ../nbs/api/00_core.ipynb #fc3c4a11a7f149ed from functools import wraps from inspect import signature, isawaitable -# %% ../nbs/api/00_core.ipynb #7eed23b7 +# %% ../nbs/api/00_core.ipynb #dcfbe520b21c8a06 def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty): "Add a parameter to a function's signature" sig = signature(f) @@ -1104,7 +1104,7 @@ def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Pa f.__annotations__[name] = typ return f -# %% ../nbs/api/00_core.ipynb #9e5a9e88 +# %% ../nbs/api/00_core.ipynb #bc3a2a5a0b908c62 class into: "Decorator to pass a route's return value into `func`, with keyword params added to the route signature" def __init__(self, func): @@ -1126,7 +1126,7 @@ async def _inner(*args, **kw): add_sig_param(_inner, k, anno, default=p.default) return _inner -# %% ../nbs/api/00_core.ipynb #1960d7ff +# %% ../nbs/api/00_core.ipynb #e287be9995e1d974 class MiddlewareBase: "Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`" async def __call__(self, scope, receive, send) -> None: @@ -1134,7 +1134,7 @@ async def __call__(self, scope, receive, send) -> None: await self._app(scope, receive, send) -# %% ../nbs/api/00_core.ipynb #83a20f93 +# %% ../nbs/api/00_core.ipynb #abd3179c8f6dab84 class FtResponse: "Wrap an FT response with any Starlette `Response`" def __init__(self, content, status_code:int=200, headers=None, cls=HTMLResponse, media_type:str|None=None, background: BackgroundTask | None = None): @@ -1149,20 +1149,20 @@ def __response__(self, req): headers = {**(self.headers or {}), **httphdrs} return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks) -# %% ../nbs/api/00_core.ipynb #9dc1025e +# %% ../nbs/api/00_core.ipynb #7ccb00230cdfb99e def unqid(seeded=False): "Random unique id, base64-encoded and prefixed with '_' so it's usable as an HTML id" id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4() res = b64encode(id4.bytes) return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-')) -# %% ../nbs/api/00_core.ipynb #5b67e014 +# %% ../nbs/api/00_core.ipynb #e70a8dcfe4386a2e def _add_ids(s): if not isinstance(s, FT): return if not getattr(s, 'id', None): s.id = unqid() for c in s.children: _add_ids(c) -# %% ../nbs/api/00_core.ipynb #1f590f25 +# %% ../nbs/api/00_core.ipynb #6a8579524fa4bb56 @patch def setup_ws(app:FastHTML, f=noop): conns = {} @@ -1174,7 +1174,7 @@ async def send(s): app._send = send return send -# %% ../nbs/api/00_core.ipynb #a8a91edd +# %% ../nbs/api/00_core.ipynb #fcb9f273985b611e devtools_loc = "/.well-known/appspecific/com.chrome.devtools.json" @patch @@ -1184,7 +1184,7 @@ def devtools_json(self:FastHTML, path=None, uuid=None): @self.route(devtools_loc) def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid)) -# %% ../nbs/api/00_core.ipynb #e27908c0 +# %% ../nbs/api/00_core.ipynb #3bf9cb548b4906fd @patch def get_client(self:FastHTML, asink=False, **kw): "Get an httpx client with session cookes set from `**kw`" @@ -1196,7 +1196,7 @@ def get_client(self:FastHTML, asink=False, **kw): client.cookies.update({self.session_cookie: data.decode()}) return client -# %% ../nbs/api/00_core.ipynb #c845f437 +# %% ../nbs/api/00_core.ipynb #6942fa7012ca3ec6 @patch def decode_session(self:FastHTML, cookie): "Decode a signed session cookie" @@ -1204,7 +1204,7 @@ def decode_session(self:FastHTML, cookie): unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None) return loads(b64decode(unsigned).decode()) -# %% ../nbs/api/00_core.ipynb #49260e9b +# %% ../nbs/api/00_core.ipynb #4e64484ff03ceb38 @patch def get_testclient(self:FastHTML, **kw): "Get a Starlette `TestClient` with session cookies set from `**kw`" diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 593b44c8..5909f72c 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1,25 +1,28 @@ { "cells": [ { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "#| default_exp core", - "id": "1ec26324bdbbb1c7" + "id": "1ec26324bdbbb1c7", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp core" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "dedbdfd343c1a82b", + "metadata": {}, "source": [ "# Core\n", "> The `FastHTML` subclass of `Starlette`." - ], - "id": "dedbdfd343c1a82b" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "6ee7a67ea3d7ac9c", + "metadata": {}, "source": [ "#| export\n", "Create an app with `app = FastHTML()`. The `@rt` decorator adds routes (`rt = app.route`), inferring what it can from the function:\n", @@ -40,7 +43,7 @@ "\n", "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", "\n", - "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", + "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. To read a different request name, use `Annotated[T, Alias('name')]`. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", "\n", " req, ws the Request / WebSocket connection\n", " sess session dict\n", @@ -61,26 +64,30 @@ "`before`/`after` functions (app-level, with `Beforeware` skip patterns, or per-route `before=`) get the same param injection as handlers. A `before` returning a value responds with it and skips the handler (the usual auth-guard pattern).\n", "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." - ], - "id": "6ee7a67ea3d7ac9c" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "f4c42f70a7d0b787" + "id": "f4c42f70a7d0b787", + "metadata": {}, + "source": [ + "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/)." + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Imports and utils", - "id": "db1b60be4448cee7" + "id": "db1b60be4448cee7", + "metadata": {}, + "source": [ + "## Imports and utils" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b7a14a169b3297a0", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess\n", @@ -109,14 +116,14 @@ "\n", "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" - ], - "id": "b7a14a169b3297a0" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "478f6d160ede5f93", + "metadata": {}, + "outputs": [], "source": [ "import time, tempfile\n", "\n", @@ -130,84 +137,92 @@ "from fastcore.test import *\n", "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" - ], - "id": "478f6d160ede5f93" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1585be45cd347d8d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _params(f): return signature_ex(f, True).parameters\n", "\n", "empty = Parameter.empty" - ], - "id": "1585be45cd347d8d" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "8f4bdd936b68acad" + "id": "8f4bdd936b68acad", + "metadata": {}, + "source": [ + "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "aecb7676f7cc2369", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def parsed_date(s:str):\n", " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" - ], - "id": "aecb7676f7cc2369" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "parsed_date('2pm')", - "id": "5d987b866e6f7454" + "id": "5d987b866e6f7454", + "metadata": {}, + "outputs": [], + "source": [ + "parsed_date('2pm')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "isinstance(date.fromtimestamp(0), date)", - "id": "ebd59c6831b3cc75" + "id": "ebd59c6831b3cc75", + "metadata": {}, + "outputs": [], + "source": [ + "isinstance(date.fromtimestamp(0), date)" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8615d8ef40e50851", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def snake2hyphens(s:str):\n", " \"Convert `s` from snake case to hyphenated and capitalised\"\n", " s = snake2camel(s)\n", " return camel2words(s, '-')" - ], - "id": "8615d8ef40e50851" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "snake2hyphens(\"snake_case\")", - "id": "cac7bfc7b00a30be" + "id": "cac7bfc7b00a30be", + "metadata": {}, + "outputs": [], + "source": [ + "snake2hyphens(\"snake_case\")" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "81ad0c31c5e60260", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "htmx_hdrs = dict(\n", @@ -233,14 +248,14 @@ "def _get_htmx(h):\n", " res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" - ], - "id": "81ad0c31c5e60260" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4ddd28db661e1dba", + "metadata": {}, + "outputs": [], "source": [ "def test_request(url: str='/', headers: dict=None, method: str='get')->Request:\n", " if headers is None: headers={}\n", @@ -248,53 +263,55 @@ " client=('127.0.0.1', 8000), server=('127.0.0.1', 8000))\n", " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" - ], - "id": "4ddd28db661e1dba" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "73f2932151861097", + "metadata": {}, + "outputs": [], "source": [ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" - ], - "id": "73f2932151861097" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "49368755a70898a0", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" - ], - "id": "49368755a70898a0" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Request and response", - "id": "ca95b6a77993c054" + "id": "ca95b6a77993c054", + "metadata": {}, + "source": [ + "## Request and response" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "58685ffc8373ba6f", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "fh_cfg = AttrDict(indent=True)" - ], - "id": "58685ffc8373ba6f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "287965806fac1332", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "_special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'}\n", @@ -303,14 +320,14 @@ " \"Check for common annotation issues; returns warning string or None\"\n", " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" - ], - "id": "287965806fac1332" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e0ed6b4ddd1014c1", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _fix_anno(t, o):\n", @@ -324,14 +341,14 @@ " if isinstance(t, type) and issubclass(t, (list,tuple)): return None\n", " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" - ], - "id": "e0ed6b4ddd1014c1" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "be37ef49e13b65d", + "metadata": {}, + "outputs": [], "source": [ "test_eq(_fix_anno(Union[str,None], 'a'), 'a')\n", "test_eq(_fix_anno(float, 0.9), 0.9)\n", @@ -345,14 +362,14 @@ "test_eq(_fix_anno(tuple, ['1','2']), None)\n", "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" - ], - "id": "be37ef49e13b65d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3b51bbad3c65426", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _form_arg(k, v, d):\n", @@ -363,63 +380,65 @@ " anno = d.get(k, None)\n", " if not anno: return v\n", " return _fix_anno(anno, v)" - ], - "id": "3b51bbad3c65426" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3e260c670966d74c", + "metadata": {}, + "outputs": [], "source": [ "d = dict(k=int, l=List[int])\n", "test_eq(_form_arg('k', \"1\", d), 1)\n", "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" - ], - "id": "3e260c670966d74c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "55342e74abb8a521", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@dataclass\n", "class HttpHeader:\n", " \"A response header as a k,v pair\"\n", " k:str;v:str" - ], - "id": "55342e74abb8a521" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4e1a41ff0d2006f8", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()\n", "\n", "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" - ], - "id": "4e1a41ff0d2006f8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "_to_htmx_header('trigger_after_settle')", - "id": "3ac6e8a48127c728" + "id": "3ac6e8a48127c728", + "metadata": {}, + "outputs": [], + "source": [ + "_to_htmx_header('trigger_after_settle')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1bcf3e6bbeb82969", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@use_kwargs_dict(**htmx_resps)\n", @@ -427,70 +446,72 @@ " \"HTMX response headers\"\n", " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" - ], - "id": "1bcf3e6bbeb82969" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "769922176702c962" + "id": "769922176702c962", + "metadata": {}, + "outputs": [], + "source": [ + "HtmxResponseHeaders(trigger_after_settle='hi')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "9efb05e73d5f5947", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _annotations(anno):\n", " \"Same as `get_annotations`, but also works on namedtuples\"\n", " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" - ], - "id": "9efb05e73d5f5947" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6843ecd419c1f999", + "metadata": {}, + "outputs": [], "source": [ "@dataclass\n", "class Foo: a:int; b:str\n", "_annotations(Foo)" - ], - "id": "6843ecd419c1f999" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7c9b92ae09b4b1ec", + "metadata": {}, + "outputs": [], "source": [ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" - ], - "id": "7c9b92ae09b4b1ec" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e5828b2e7682c383", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" - ], - "id": "e5828b2e7682c383" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a3058a8b3943fd8d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _formitem(form, k):\n", @@ -499,53 +520,53 @@ " if isinstance(form, dict): return form.get(k)\n", " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" - ], - "id": "a3058a8b3943fd8d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3c3233e509f6970e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def form2dict(form: FormData) -> dict:\n", " \"Convert starlette form data to a dict\"\n", " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" - ], - "id": "3c3233e509f6970e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d5ea2e7798493538", + "metadata": {}, + "outputs": [], "source": [ "d = [('a',1),('a',2),('b',0)]\n", "fd = FormData(d)\n", "res = form2dict(fd)\n", "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" - ], - "id": "d5ea2e7798493538" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "da31ece677242cc6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "DEF_MAXPART = 100*1024*1024" - ], - "id": "da31ece677242cc6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "73947311e8867ddf", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def parse_form(req: Request) -> FormData:\n", @@ -565,48 +586,50 @@ " if not isinstance(res, dict): raise HTTPException(400, \"JSON body must be an object\")\n", " return res\n", " return await req.form(max_part_size=maxpart)" - ], - "id": "73947311e8867ddf" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Use the special `__from_request__` class method for custom creation:", - "id": "8dc378792d632ae2" + "id": "8dc378792d632ae2", + "metadata": {}, + "source": [ + "Use the special `__from_request__` class method for custom creation:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "28ed01aae9ac888b", + "metadata": {}, + "outputs": [], "source": [ "class Foo(BasicRepr):\n", " def __init__(self, d): self.d = d\n", "\n", " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" - ], - "id": "28ed01aae9ac888b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "91fae17bd4925e81", + "metadata": {}, + "outputs": [], "source": [ "p = Parameter('a', Parameter.POSITIONAL_OR_KEYWORD, annotation=Foo)\n", "\n", "req = test_request(method='POST')\n", "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" - ], - "id": "91fae17bd4925e81" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e80f45b5f2d1c328", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _from_body(conn, p, data):\n", @@ -623,14 +646,14 @@ " if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data\n", " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" - ], - "id": "e80f45b5f2d1c328" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "eb20d63ddb8c94ef", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class ApiReturn:\n", @@ -640,20 +663,22 @@ " def __init__(self, isapi=False): self.isapi = isapi\n", " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" - ], - "id": "eb20d63ddb8c94ef" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "dc6b0adc9f6733b3" + "id": "dc6b0adc9f6733b3", + "metadata": {}, + "source": [ + "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "61ac79f78f6492d6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class JSONResponse(JSONResponseOrig):\n", @@ -662,14 +687,14 @@ " def _default(o): return list(o) if is_listy(o) else str(o)\n", " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" - ], - "id": "61ac79f78f6492d6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "178e01facf162f65", + "metadata": {}, + "outputs": [], "source": [ "async def f(req):\n", " def _f(p:HttpHeader): ...\n", @@ -683,14 +708,14 @@ "d = dict(k='value1',v=['value2','value3'])\n", "response = client.post('/', data=d)\n", "print(response.json())" - ], - "id": "178e01facf162f65" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7da5229a3769ed", + "metadata": {}, + "outputs": [], "source": [ "# An empty `application/json` body yields `{}` (like the empty-multipart guard), not a 500\n", "async def _empty_json(req): return JSONResponse(form2dict(await parse_form(req)))\n", @@ -700,65 +725,69 @@ "test_eq(res.json(), {})\n", "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" - ], - "id": "7da5229a3769ed" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "17448e54e1c0f79b" + "id": "17448e54e1c0f79b", + "metadata": {}, + "source": [ + "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c53e378d80aef7e5", + "metadata": {}, + "outputs": [], "source": [ "bad = b'--xyzXX\\r\\nContent-Disposition: form-data; name=\"a\"\\r\\n\\r\\n1\\r\\n--xyz--\\r\\n'\n", "res = client.post('/', content=bad, headers={'content-type':'multipart/form-data; boundary=xyz'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ], - "id": "c53e378d80aef7e5" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "3a468526a5f83f29" + "id": "3a468526a5f83f29", + "metadata": {}, + "source": [ + "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8e67736be407e8ad", + "metadata": {}, + "outputs": [], "source": [ "res = client.post('/', json=[1,2])\n", "test_eq(res.status_code, 400)\n", "res = client.post('/', content=b'not json', headers={'content-type':'application/json'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ], - "id": "8e67736be407e8ad" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5a8f91f6d88d1ef4", + "metadata": {}, + "outputs": [], "source": [ "async def f(req): return Response(str(req.query_params.getlist('x')))\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" - ], - "id": "5a8f91f6d88d1ef4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4d46f096ae3f0e05", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@dataclass(frozen=True)\n", @@ -783,30 +812,30 @@ " if issubclass(anno, State): return conn.scope['app'].state\n", " if anno is dict: return data\n", " if _is_body(anno):\n", - " if 'session'.startswith(arg.lower()): return conn.scope.get('session', {})\n", + " if 'session'.startswith(key.lower()): return conn.scope.get('session', {})\n", " return await _from_body(conn, p, data)\n", - " if (msg := _check_anno(arg, anno)): return warn(msg)\n", + " if (msg := _check_anno(key, anno)): return warn(msg)\n", " # Special param names with no annotations\n", " if anno is empty:\n", - " if arg.lower()=='ws' or 'request'.startswith(arg.lower()): return conn\n", - " if 'session'.startswith(arg.lower()): return conn.scope.get('session', {})\n", - " if arg.lower()=='scope': return conn.scope\n", - " if arg.lower()=='data': return data\n", - " if arg.lower()=='htmx': return _get_htmx(hdrs)\n", - " if arg.lower()=='app': return conn.scope['app']\n", - " if arg.lower()=='state': return conn.scope['app'].state\n", - " if arg.lower()=='auth': return conn.scope.get('auth', None)\n", - " if arg.lower()=='send':\n", + " if key.lower()=='ws' or 'request'.startswith(key.lower()): return conn\n", + " if 'session'.startswith(key.lower()): return conn.scope.get('session', {})\n", + " if key.lower()=='scope': return conn.scope\n", + " if key.lower()=='data': return data\n", + " if key.lower()=='htmx': return _get_htmx(hdrs)\n", + " if key.lower()=='app': return conn.scope['app']\n", + " if key.lower()=='state': return conn.scope['app'].state\n", + " if key.lower()=='auth': return conn.scope.get('auth', None)\n", + " if key.lower()=='send':\n", " assert not isinstance(conn, Request), \"`send` requires a websocket, not a `Request`\"\n", " return partial(_send_ws, conn)\n", - " if arg.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json')\n", - " if arg.lower()=='body': return (await conn.body()).decode()\n", - " if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower())\n", + " if key.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json')\n", + " if key.lower()=='body': return (await conn.body()).decode()\n", + " if key.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, key.lower())\n", " return None\n", " # Not a special name or a special annotation\n", " res = conn.path_params.get(key, None)\n", " if res in (empty,None): res = conn.cookies.get(key, None)\n", - " if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None)\n", + " if res in (empty,None): res = hdrs.get(snake2hyphens(key), None)\n", " if res in (empty,None): res = _formitem(conn.query_params, key)\n", " if res in (empty,None): res = _formitem(data, key)\n", " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values`\n", @@ -819,51 +848,51 @@ " except ValueError as e:\n", " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" - ], - "id": "4d46f096ae3f0e05" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5849a37703c71c4b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _find_ps(conn, data, hdrs, params):\n", " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" - ], - "id": "5849a37703c71c4b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d4f32846cec05208", + "metadata": {}, + "outputs": [], "source": [ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" - ], - "id": "d4f32846cec05208" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b4a0893dd0cac34a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wrap_req(req, params):\n", " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" - ], - "id": "b4a0893dd0cac34a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "29d868c520d765ea", + "metadata": {}, + "outputs": [], "source": [ "def g(test_val:int=0, item:HttpHeader=None, htmx:HtmxHeaders=None, api:ApiReturn=None): ...\n", "\n", @@ -876,14 +905,14 @@ "r = client.post('/', data=dict(test_val='21', k='a', v='b'), headers={'HX-Request': '1'})\n", "test_eq(r.status_code, 200)\n", "print(r.text)" - ], - "id": "29d868c520d765ea" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c0724d784ab594d1", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader, state): ...\n", "\n", @@ -894,14 +923,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ], - "id": "c0724d784ab594d1" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d0f92af0fbc7c0fd", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader): ...\n", "\n", @@ -912,14 +941,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ], - "id": "d0f92af0fbc7c0fd" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b396c52f3346caa7", + "metadata": {}, + "outputs": [], "source": [ "class Bar(BasicRepr):\n", " @classmethod\n", @@ -934,26 +963,30 @@ "\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" - ], - "id": "b396c52f3346caa7" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "**Missing Request Params**", - "id": "78335a952aea05ad" + "id": "78335a952aea05ad", + "metadata": {}, + "source": [ + "**Missing Request Params**" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "a91254ccdac6ce7c" + "id": "a91254ccdac6ce7c", + "metadata": {}, + "source": [ + "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b3db13a8d0036d32", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str=''): ...\n", "\n", @@ -964,20 +997,22 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ], - "id": "b3db13a8d0036d32" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "7f4162c89254077e" + "id": "7f4162c89254077e", + "metadata": {}, + "source": [ + "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "fb7d7837e7ff6ba2", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str): ...\n", "\n", @@ -988,14 +1023,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ], - "id": "fb7d7837e7ff6ba2" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4b264f3ec9a80e3a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def flat_xt(lst):\n", @@ -1006,83 +1041,85 @@ " if isinstance(item, (list,tuple)): result.extend(item)\n", " else: result.append(item)\n", " return tuple(result)" - ], - "id": "4b264f3ec9a80e3a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d5ee5b99fc8fc445", + "metadata": {}, + "outputs": [], "source": [ "x = ft('a',1)\n", "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" - ], - "id": "d5ee5b99fc8fc445" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4b57d3d58af69e51", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class Beforeware:\n", " \"Wrap a `before` function with `skip` path patterns that bypass it\"\n", " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" - ], - "id": "4b57d3d58af69e51" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "de37dcd7b5641749", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" - ], - "id": "de37dcd7b5641749" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "868b208fabcce9b6", + "metadata": {}, + "outputs": [], "source": [ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" - ], - "id": "868b208fabcce9b6" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Websockets / SSE", - "id": "919baa8e3eb29d22" + "id": "919baa8e3eb29d22", + "metadata": {}, + "source": [ + "## Websockets / SSE" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "761597782f7f9df6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wrap_ws(ws, data, params):\n", " raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers`\n", " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" - ], - "id": "761597782f7f9df6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7a3be2b2f30770d7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _send_ws(ws, resp):\n", @@ -1111,14 +1148,14 @@ " if disconn: cls.on_disconnect = _disconnect\n", " cls.on_receive = _recv\n", " return cls" - ], - "id": "7a3be2b2f30770d7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a2237401400886a2", + "metadata": {}, + "outputs": [], "source": [ "def on_receive(msg:str): return f\"Message text was: {msg}\"\n", "c = _ws_endp(on_receive)\n", @@ -1127,14 +1164,14 @@ " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" - ], - "id": "a2237401400886a2" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a73456aac0e74ecb", + "metadata": {}, + "outputs": [], "source": [ "def on_receive(hx_trigger:str): return f\"trigger: {hx_trigger}\"\n", "cli = TestClient(Starlette(routes=[WebSocketRoute('/', _ws_endp(on_receive))]))\n", @@ -1142,27 +1179,27 @@ " ws.send_text(json.dumps({\"HEADERS\": {\"Hx-Trigger\": \"my-btn\"}}))\n", " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" - ], - "id": "a73456aac0e74ecb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ddc64ff604c831b8", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def EventStream(s):\n", " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" - ], - "id": "ddc64ff604c831b8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2e11648d17d6d6f5", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def signal_shutdown():\n", @@ -1175,58 +1212,60 @@ " self.force_exit = True\n", " self._orig_handle_exit(*args, **kwargs)\n", " return event" - ], - "id": "2e11648d17d6d6f5" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Routing and application", - "id": "fc2f9df0b33fc305" + "id": "fc2f9df0b33fc305", + "metadata": {}, + "source": [ + "## Routing and application" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f660c44ff209b90c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def uri(_arg, **kwargs):\n", " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" - ], - "id": "f660c44ff209b90c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3e45f287f5af27c5", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def decode_uri(s):\n", " \"Decode a URI created by `uri()` back into argument and keyword dict\"\n", " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" - ], - "id": "3e45f287f5af27c5" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "580afa399f3bd333", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "from starlette.convertors import StringConvertor" - ], - "id": "580afa399f3bd333" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2bb9a1ff5768ba68", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "StringConvertor.regex = \"[^/]*\" # `+` replaced with `*`\n", @@ -1237,28 +1276,28 @@ " assert \"/\" not in value, \"May not contain path separators\"\n", " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" - ], - "id": "2bb9a1ff5768ba68" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d99b78e3a99c3ec0", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", "def url_path_for(self:HTTPConnection, name: str, **path_params):\n", " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" - ], - "id": "d99b78e3a99c3ec0" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c4d78aa8d576e6cc", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "_verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href')\n", @@ -1285,25 +1324,25 @@ " \"Convert response to XML string with target URL resolution\"\n", " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" - ], - "id": "c4d78aa8d576e6cc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f2f473dc3aa8e232", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" - ], - "id": "f2f473dc3aa8e232" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ce31ff717114b811", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def flat_tuple(o):\n", @@ -1315,27 +1354,27 @@ " if isinstance(item, _iter_typs): result.extend(list(item))\n", " else: result.append(item)\n", " return tuple(result)" - ], - "id": "ce31ff717114b811" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "aaf0c02fc0142c20", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def noop_body(c, req):\n", " \"Default Body wrap function which just returns the content\"\n", " return c" - ], - "id": "aaf0c02fc0142c20" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6df817363734849b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def respond(req, heads, bdy):\n", @@ -1345,37 +1384,37 @@ " bw_args = (bdy, req) if len(params)>1 else (bdy,)\n", " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" - ], - "id": "6df817363734849b" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "2a6054f74f4c9f90", + "metadata": {}, "source": [ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" - ], - "id": "2a6054f74f4c9f90" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2f96c17575a317a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def is_full_page(req, resp):\n", " \"Check if response should be rendered as full page or fragment\"\n", " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" - ], - "id": "2f96c17575a317a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "99a2f618ca8e26e9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _part_resp(req, resp):\n", @@ -1393,14 +1432,14 @@ " resp = tuple(resp)\n", " if len(resp)==1: resp = resp[0]\n", " return resp,kw" - ], - "id": "99a2f618ca8e26e9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d18dc765f6c377c3", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _canonical(req):\n", @@ -1417,27 +1456,27 @@ " title = [] if any(getattr(o, 'tag', '')=='title' for o in heads) else [Title(req.app.title)]\n", " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" - ], - "id": "d18dc765f6c377c3" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5afa1b8942912373", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _is_ft_resp(resp):\n", " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" - ], - "id": "5afa1b8942912373" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "67c986c99e7a6231", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _resp(req, resp, cls=empty, status_code=200):\n", @@ -1458,14 +1497,14 @@ " resp = str(resp)\n", " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" - ], - "id": "67c986c99e7a6231" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "15e507d2acd3232e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class Redirect:\n", @@ -1474,28 +1513,28 @@ " def __response__(self, req):\n", " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" - ], - "id": "15e507d2acd3232e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "92a6fdb45add90f0", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wrap_call(f, req, params):\n", " \"Wrap function call with request parameter injection\"\n", " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" - ], - "id": "92a6fdb45add90f0" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "49c3d12b947fa377", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "# chkstyle: skip\n", @@ -1516,28 +1555,32 @@ " \"live\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js\",\n", " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js\"\n", "}" - ], - "id": "49c3d12b947fa377" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "The FastHTML `exts` param supports the following:", - "id": "cf08377dacfc7dea" + "id": "cf08377dacfc7dea", + "metadata": {}, + "source": [ + "The FastHTML `exts` param supports the following:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "print(' '.join(htmx_exts))", - "id": "62a280661c939ed3" + "id": "62a280661c939ed3", + "metadata": {}, + "outputs": [], + "source": [ + "print(' '.join(htmx_exts))" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b31e24130b390101", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", @@ -1547,14 +1590,14 @@ "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" - ], - "id": "b31e24130b390101" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "af5fa00cfaef7a8b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def get_key(key=None, fname='.sesskey'):\n", @@ -1565,27 +1608,27 @@ " key = str(uuid.uuid4())\n", " fname.write_text(key)\n", " return key" - ], - "id": "af5fa00cfaef7a8b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "17ff3158cc805754", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _list(o):\n", " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" - ], - "id": "17ff3158cc805754" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d2a4061192d883e8", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap):\n", @@ -1596,14 +1639,14 @@ " res = await _handle(f, req, exc)\n", " return _resp(req, res, status_code=status_code)\n", " return _f" - ], - "id": "d2a4061192d883e8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f0ddf2219c879e3b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def qp(p:str, **kw) -> str:\n", @@ -1616,57 +1659,63 @@ " p = re.sub(r'\\{([^:}]+)(:.+?)?}', _sub, p)\n", " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" - ], - "id": "f0ddf2219c879e3b" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`qp` adds query parameters to route path strings", - "id": "e117438a658aa761" + "id": "e117438a658aa761", + "metadata": {}, + "source": [ + "`qp` adds query parameters to route path strings" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "12f6b4803592427c" + "id": "12f6b4803592427c", + "metadata": {}, + "outputs": [], + "source": [ + "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3e8752f87bea162b", + "metadata": {}, + "outputs": [], "source": [ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" - ], - "id": "3e8752f87bea162b" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "10f6e76c48f0572c" + "id": "10f6e76c48f0572c", + "metadata": {}, + "source": [ + "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "19c390817bbfbdaf", + "metadata": {}, + "outputs": [], "source": [ "path = '/foo/{a}/{d}/{ab:int}'\n", "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" - ], - "id": "19c390817bbfbdaf" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8add43015a5b4c0c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def def_hdrs(htmx=True, htmx4=False, surreal=True):\n", @@ -1680,14 +1729,14 @@ " # TODO: Check if fhjsscr works with htmx4\n", " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" - ], - "id": "8add43015a5b4c0c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "54f3bb368193da5c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])\n", @@ -1713,14 +1762,14 @@ " }\n", " };\n", "\"\"\"))\n" - ], - "id": "54f3bb368193da5c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "876c9c8cb63f2cf2", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class _LifespanCtx:\n", @@ -1748,14 +1797,14 @@ " for f in self.shutdown: await _handle(f)\n", "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" - ], - "id": "876c9c8cb63f2cf2" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d8d78a64be4bf27", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class FastHTML(Starlette):\n", @@ -1795,20 +1844,22 @@ " super().__init__(debug, routes, middleware=middleware, exception_handlers=excs, lifespan=self.lifespan)\n", "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" - ], - "id": "d8d78a64be4bf27" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "### Routes", - "id": "76d26b7b94050a73" + "id": "76d26b7b94050a73", + "metadata": {}, + "source": [ + "### Routes" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b000f60adf333122", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class HostRoute(Route):\n", @@ -1827,14 +1878,14 @@ " def __repr__(self) -> str:\n", " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" - ], - "id": "b000f60adf333122" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d673eda936d3ac03", + "metadata": {}, + "outputs": [], "source": [ "app = FastHTML()\n", "app.router.routes = []\n", @@ -1847,14 +1898,14 @@ "test_eq(cli.get('/', headers={'host': 'localhost'}).text, 'main page')\n", "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" - ], - "id": "d673eda936d3ac03" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c2ba4ddbb35095f", + "metadata": {}, + "outputs": [], "source": [ "app.router.routes.append(HostRoute('/hello', lambda _: Response('api hello'), host='api.{parentdom}', methods=['GET'], name='api_hello'))\n", "app.router.routes.append(HostRoute('/hello', lambda _: Response('normal hello'), methods=['GET'], name='hello'))\n", @@ -1863,14 +1914,14 @@ "test_eq(cli.get('/hello', headers={'host': 'mysite.com'}).text, 'normal hello')\n", "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" - ], - "id": "c2ba4ddbb35095f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a1583c0bcb0420f2", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1882,25 +1933,25 @@ " ((route.methods is None) or (set(r.methods) == set(route.methods))))]\n", " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" - ], - "id": "a1583c0bcb0420f2" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4917c9f35ccd9f96", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" - ], - "id": "4917c9f35ccd9f96" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "46ca67a58ad666b9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1930,14 +1981,14 @@ " if nr: resp = nr\n", " return _resp(req, resp, sig.return_annotation)\n", " return _f" - ], - "id": "46ca67a58ad666b9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a0744b04d81228d6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1948,14 +1999,14 @@ " route.methods = ['ws']\n", " self.add_route(route)\n", " return func" - ], - "id": "a0744b04d81228d6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2fc600349ae25291", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1963,28 +2014,28 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" - ], - "id": "2fc600349ae25291" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "17f6217b9e528764", + "metadata": {}, + "outputs": [], "source": [ "#|export\n", "@patch\n", "def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None):\n", " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" - ], - "id": "17f6217b9e528764" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "48e0a2670e869b6b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _mk_locfunc(f, p, app=None):\n", @@ -1999,50 +2050,50 @@ " def __str__(self): return p\n", "\n", " return _lf()" - ], - "id": "48e0a2670e869b6b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a0c09d4bddc479a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def nested_name(f):\n", " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" - ], - "id": "a0c09d4bddc479a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1cadfcaba56484f5", + "metadata": {}, + "outputs": [], "source": [ "def f():\n", " def g(): ...\n", " return g" - ], - "id": "1cadfcaba56484f5" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "14dc3b2b623ea0f5", + "metadata": {}, + "outputs": [], "source": [ "func = f()\n", "nested_name(func)" - ], - "id": "14dc3b2b623ea0f5" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8f0115f7adff7835", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -2055,14 +2106,14 @@ " handler = getattr(cls, meth, None)\n", " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" - ], - "id": "8f0115f7adff7835" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "329e78f5bbc6ded1", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _route_pn(func, path, name):\n", @@ -2088,14 +2139,14 @@ " lf = _mk_locfunc(func, p, app=self)\n", " lf.__routename__ = n\n", " return lf" - ], - "id": "329e78f5bbc6ded1" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ebf0ff44a915aa0c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -2106,47 +2157,49 @@ " return f(path) if callable(path) else f\n", "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" - ], - "id": "ebf0ff44a915aa0c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": [ + "id": "d7e65f487811260a", + "metadata": {}, + "outputs": [], + "source": [ "app = FastHTML()\n", "@app.get\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ], - "id": "d7e65f487811260a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "256527283aa9a22c", + "metadata": {}, + "outputs": [], "source": [ "@app.get('/foo/{a}')\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ], - "id": "256527283aa9a22c" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "### Utils", - "id": "8e67c9cd986ebcad" + "id": "8e67c9cd986ebcad", + "metadata": {}, + "source": [ + "### Utils" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "94d6f8f81d93aa5e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -2154,14 +2207,14 @@ " \"Set the lifespan context manager for the FastHTML app\"\n", " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" - ], - "id": "94d6f8f81d93aa5e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b5e55224f5a20d18", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def serve(\n", @@ -2188,20 +2241,22 @@ " link = f'http://{\"localhost\" if host==\"0.0.0.0\" else host}:{port}'\n", " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" - ], - "id": "b5e55224f5a20d18" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "a5732f9665767807" + "id": "a5732f9665767807", + "metadata": {}, + "source": [ + "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ddc667c24b6df7dc", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _spawn(n):\n", @@ -2209,20 +2264,22 @@ " ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)]\n", " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" - ], - "id": "ddc667c24b6df7dc" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "6562a9b295a6e9f8" + "id": "6562a9b295a6e9f8", + "metadata": {}, + "source": [ + "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "78dc3bffc42b5d1", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wait_disconnect(req):\n", @@ -2238,20 +2295,22 @@ " finally:\n", " watch.cancel()\n", " if not work.done(): work.cancel()" - ], - "id": "78dc3bffc42b5d1" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "a4d7ea6dd8b142af" + "id": "a4d7ea6dd8b142af", + "metadata": {}, + "source": [ + "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3ddda418760bb472", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def cancel_on_disconnect(\n", @@ -2266,14 +2325,14 @@ " c = until_disconnect(__req, f(**kw))\n", " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" - ], - "id": "3ddda418760bb472" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "33e9385190411631", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class Client:\n", @@ -2287,55 +2346,63 @@ " with from_thread.start_blocking_portal() as portal: return portal.call(_request)\n", "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" - ], - "id": "33e9385190411631" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "201775479e8c5e4d", + "metadata": {}, + "outputs": [], "source": [ "app = FastHTML(routes=[Route('/', lambda _: Response('test'))])\n", "cli = Client(app)\n", "\n", "cli.get('/').text" - ], - "id": "201775479e8c5e4d" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "a935603ded43699" + "id": "a935603ded43699", + "metadata": {}, + "source": [ + "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable." + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## FastHTML Tests", - "id": "6b039d58b7ad938c" + "id": "6b039d58b7ad938c", + "metadata": {}, + "source": [ + "## FastHTML Tests" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "7447e238bc623b35" + "id": "7447e238bc623b35", + "metadata": {}, + "outputs": [], + "source": [ + "def get_cli(app): return app,TestClient(app),app.route" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "1bf62de2b22c7f0c" + "id": "1bf62de2b22c7f0c", + "metadata": {}, + "outputs": [], + "source": [ + "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d90ee04d8045202b", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML(title=\"My Custom Title\"))\n", "@app.get\n", @@ -2347,14 +2414,14 @@ "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ], - "id": "d90ee04d8045202b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "47ef7be9d158140f", + "metadata": {}, + "outputs": [], "source": [ "@rt(host='test.foo')\n", "def foo(): return Div(\"Goodbye World\")\n", @@ -2363,27 +2430,27 @@ "\n", "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" - ], - "id": "47ef7be9d158140f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b943b2cdaf919cb4", + "metadata": {}, + "outputs": [], "source": [ "response = cli.get('/foo')\n", "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ], - "id": "b943b2cdaf919cb4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "92ccb75904de8458", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -2392,26 +2459,26 @@ "\n", "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" - ], - "id": "92ccb75904de8458" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "20cc2bf645b7006d", + "metadata": {}, + "outputs": [], "source": [ "def test_r(cli, path, exp, meth='get', hx=False, **kwargs):\n", " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" - ], - "id": "20cc2bf645b7006d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "944a5a468d63cacd", + "metadata": {}, + "outputs": [], "source": [ "@dataclass\n", "class Card: id:str; title:str\n", @@ -2426,14 +2493,14 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ], - "id": "944a5a468d63cacd" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b1237fb2b56936f7", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "class Things:\n", @@ -2441,54 +2508,54 @@ "\n", "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" - ], - "id": "b1237fb2b56936f7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8c9af7970bef9521", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/hi\")\n", "def get(): return 'Hi there'\n", "\n", "r = cli.get('/hi')\n", "r.text" - ], - "id": "8c9af7970bef9521" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c61831785468d85", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/hi\")\n", "def post(): return 'Postal'\n", "\n", "cli.post('/hi').text" - ], - "id": "c61831785468d85" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5b9b648519a9841", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/hostie\")\n", "def show_host(req): return req.headers['host']\n", "\n", "cli.get('/hostie').text" - ], - "id": "5b9b648519a9841" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c8c78e969d888a8", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/setsess\")\n", "def set_sess(session):\n", @@ -2504,100 +2571,100 @@ " data = ws.receive_text()\n", "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" - ], - "id": "c8c78e969d888a8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5ae370e0b73ee395", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def yoyo(): return 'a yoyo'\n", "\n", "cli.post('/yoyo').text" - ], - "id": "5ae370e0b73ee395" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6910ecf551279150", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" - ], - "id": "6910ecf551279150" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a7ee02f019c404e9", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" - ], - "id": "a7ee02f019c404e9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3fd2e6ee0954afe7", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" - ], - "id": "3fd2e6ee0954afe7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "97af5cc25eac0664", + "metadata": {}, + "outputs": [], "source": [ "@rt('/user/{nm}', name='gday')\n", "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" - ], - "id": "97af5cc25eac0664" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "79fe68f310ade29e", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" - ], - "id": "79fe68f310ade29e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4c9d807ad8da2122", + "metadata": {}, + "outputs": [], "source": [ "@rt('/link')\n", "def get(req): return f\"{req.url_for('gday', nm='Alexis')}; {req.url_for('show_host')}\"\n", "\n", "cli.get('/link').text" - ], - "id": "4c9d807ad8da2122" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7c0d763cf3ba98d4", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/background\")\n", "async def background_task(request):\n", @@ -2607,22 +2674,24 @@ " return P(\"Task started\"), BackgroundTask(long_running_task)\n", "\n", "response = cli.get(\"/background\")" - ], - "id": "7c0d763cf3ba98d4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "d30c01e9258d35de" + "id": "d30c01e9258d35de", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "398b0258281fbb65", + "metadata": {}, + "outputs": [], "source": [ "hxhdr = {'headers':{'hx-request':\"1\"}}\n", "\n", @@ -2645,62 +2714,62 @@ "\n", "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" - ], - "id": "398b0258281fbb65" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "eeccde59428d6528", + "metadata": {}, + "outputs": [], "source": [ "# declaration-time warnings\n", "def _noanno(nope): return nope\n", "def _bare(names:list): return str(names)\n", "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" - ], - "id": "eeccde59428d6528" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "57158b1cf3f65ee", + "metadata": {}, + "outputs": [], "source": [ "# runtime warnings\n", "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" - ], - "id": "57158b1cf3f65ee" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7677f45b7e3fd7f9", + "metadata": {}, + "outputs": [], "source": [ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" - ], - "id": "7677f45b7e3fd7f9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "accee413b1b66b81", + "metadata": {}, + "outputs": [], "source": [ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" - ], - "id": "accee413b1b66b81" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f8f507e3ab671ab", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/models/{nm}\")\n", "def get(nm:ModelName): return nm\n", @@ -2719,14 +2788,14 @@ "\n", "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" - ], - "id": "f8f507e3ab671ab" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4fb9b3ffc59ce351", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/html/1', headers={'hx-request':\"1\"})\n", "assert '

Next is 2.

' in r.text\n", @@ -2740,14 +2809,14 @@ "assert cli.get('/idxl/?idx=1&idx=g').status_code == 404\n", "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" - ], - "id": "4fb9b3ffc59ce351" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2e1dda2d25a8e885", + "metadata": {}, + "outputs": [], "source": [ "app = FastHTML()\n", "rt = app.route\n", @@ -2757,30 +2826,34 @@ "@app.route(r'/static/{path:path}')\n", "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" - ], - "id": "2e1dda2d25a8e885" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "7e193da57daea07b" + "id": "7e193da57daea07b", + "metadata": {}, + "outputs": [], + "source": [ + "cli.get('/static/sub/a.b?a=1').text" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "app.chk = 'foo'", - "id": "f87c9a5d4ea90f29" + "id": "f87c9a5d4ea90f29", + "metadata": {}, + "outputs": [], + "source": [ + "app.chk = 'foo'" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "503d7efff37dbc47", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/booly/\")\n", "def _(coming:bool=True): return 'Coming' if coming else 'Not coming'\n", @@ -2808,14 +2881,14 @@ "\n", "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" - ], - "id": "503d7efff37dbc47" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "263790ff5a34a051", + "metadata": {}, + "outputs": [], "source": [ "test_r(cli, '/booly/?coming=true', 'Coming')\n", "test_r(cli, '/booly/?coming=no', 'Not coming')\n", @@ -2825,59 +2898,59 @@ "test_r(cli, '/hxtest' , '1', headers={'HX-Request':'1'})\n", "test_r(cli, '/hxtest2', '1', headers={'HX-Request':'1'})\n", "test_r(cli, '/app' , 'foo')" - ], - "id": "263790ff5a34a051" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b776724e467f3653", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app2', **hxhdr)\n", "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" - ], - "id": "b776724e467f3653" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "9370a01559dacd73", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" - ], - "id": "9370a01559dacd73" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "236a2baa2c8b9c1a", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" - ], - "id": "236a2baa2c8b9c1a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "963cb71a663fcd10", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" - ], - "id": "963cb71a663fcd10" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e5b1bb2a658ea4e2", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def meta(): return ((Title('hi'), H1('hi')), (Meta(property='image'), Meta(property='site_name')))\n", @@ -2885,28 +2958,28 @@ "t = cli.post('/meta').text\n", "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" - ], - "id": "c796ffa8bb41da4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "9179df36c8b757c6", + "metadata": {}, + "outputs": [], "source": [ "# Testing POST with Content-Type: application/json\n", "@app.post(\"/bodytext\")\n", @@ -3011,22 +3086,24 @@ "\n", "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" - ], - "id": "9179df36c8b757c6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "fd78230d02188eba" + "id": "fd78230d02188eba", + "metadata": {}, + "outputs": [], + "source": [ + "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2faaeeb96a798748", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/uploads\")\n", "async def post(files:list[UploadFile]): return ','.join([(await file.read()).decode() for file in files])\n", @@ -3034,26 +3111,26 @@ "res = cli.post('/uploads', files=files)\n", "print(res.status_code)\n", "print(res.text)" - ], - "id": "2faaeeb96a798748" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "820cbae8934798f5", + "metadata": {}, + "outputs": [], "source": [ "res = cli.post('/uploads', files=[files[0]])\n", "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" - ], - "id": "820cbae8934798f5" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e988e94bfe6fba68", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/setsess\")\n", "def get(sess, foo:str=''):\n", @@ -3068,14 +3145,14 @@ "time.sleep(0.01)\n", "\n", "cli.get('/getsess').text" - ], - "id": "e988e94bfe6fba68" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bf2d409c0709af67", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/sess-first\")\n", "def post(sess, name: str):\n", @@ -3088,59 +3165,61 @@ "def get(sess): return sess['name']\n", "\n", "test_eq(cli.get('/getsess-all').text, '2')" - ], - "id": "bf2d409c0709af67" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "df8d61ec976265b6", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/upload\")\n", "async def post(uf:UploadFile): return (await uf.read()).decode()\n", "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" - ], - "id": "df8d61ec976265b6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "cbec41e8d6ebffbf", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/form-submit/{list_id}\")\n", "def options(list_id: str):\n", " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" - ], - "id": "cbec41e8d6ebffbf" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f1eb7890173765c6", + "metadata": {}, + "outputs": [], "source": [ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" - ], - "id": "f1eb7890173765c6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "from fasthtml.authmw import user_pwd_auth", - "id": "7482ef2bc589b29a" + "id": "7482ef2bc589b29a", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.authmw import user_pwd_auth" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6f6ce6fd47d4b248", + "metadata": {}, + "outputs": [], "source": [ "def _not_found(req, exc): return Div('nope')\n", "\n", @@ -3149,14 +3228,14 @@ "txt = cli.get('/').text\n", "assert '
nope
' in txt\n", "assert '' in txt" - ], - "id": "6f6ce6fd47d4b248" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5304bb2450fc035a", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3165,14 +3244,14 @@ "\n", "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" - ], - "id": "5304bb2450fc035a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "38bb1202868cd0df", + "metadata": {}, + "outputs": [], "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3182,14 +3261,14 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ], - "id": "38bb1202868cd0df" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2e8d23e994bcbe96", + "metadata": {}, + "outputs": [], "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3199,28 +3278,32 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ], - "id": "2e8d23e994bcbe96" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Check that afterware like toasts are called correctly.", - "id": "4bd6dcf2a8549622" + "id": "4bd6dcf2a8549622", + "metadata": {}, + "source": [ + "Check that afterware like toasts are called correctly." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "from fasthtml.toaster import *", - "id": "6cffaaba44df4e12" + "id": "6cffaaba44df4e12", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.toaster import *" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "36d8890a984cde1b", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3255,28 +3338,32 @@ "\n", "r = cli.get('/')\n", "assert tm in r.text" - ], - "id": "36d8890a984cde1b" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "853dfc9923eb07d8" + "id": "853dfc9923eb07d8", + "metadata": {}, + "source": [ + "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762)." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "14e38fb55813792c" + "id": "14e38fb55813792c", + "metadata": {}, + "outputs": [], + "source": [ + "assert 'id=\"fh-toast-container\"' in r.text" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d5bb7f15f5fcaa7a", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML(on_startup=[lambda: setattr(app, '_started', True)],\n", " on_shutdown=[lambda: setattr(app, '_stopped', True)]))\n", @@ -3286,14 +3373,14 @@ "\n", "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" - ], - "id": "d5bb7f15f5fcaa7a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8902976232842503", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3304,20 +3391,22 @@ "def index(): return app._evt\n", "\n", "with cli: test_eq(cli.get('/').text, 'fired')" - ], - "id": "8902976232842503" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "a0d45421f11c25b9" + "id": "a0d45421f11c25b9", + "metadata": {}, + "source": [ + "A route-level `before` can set session data, short-circuit with a response, or raise to block access:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7cca7cdab970d0d5", + "metadata": {}, + "outputs": [], "source": [ "def check_user(sess, user:str=''):\n", " if user == 'admin':\n", @@ -3332,20 +3421,22 @@ "test_eq(cli.get('/dashboard?user=admin').text, 'bar')\n", "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" - ], - "id": "7cca7cdab970d0d5" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "bbb9a2213e17475e" + "id": "bbb9a2213e17475e", + "metadata": {}, + "source": [ + "Multiple `before` functions run in order, each receiving the injected request params:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4bde7a5b2cce99a8", + "metadata": {}, + "outputs": [], "source": [ "def set_a(sess): sess['a'] = 1\n", "def set_b(sess): sess['b'] = 2\n", @@ -3354,20 +3445,22 @@ "def get(sess): return f\"{sess.get('a')},{sess.get('b')}\"\n", "\n", "test_eq(cli.get('/multi').text, '1,2')" - ], - "id": "4bde7a5b2cce99a8" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "744158410961fec" + "id": "744158410961fec", + "metadata": {}, + "source": [ + "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e8ef438896db58e", + "metadata": {}, + "outputs": [], "source": [ "calls = []\n", "def first(sess): \n", @@ -3380,20 +3473,22 @@ "\n", "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" - ], - "id": "e8ef438896db58e" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "88f8a5f93a0ef7a" + "id": "88f8a5f93a0ef7a", + "metadata": {}, + "source": [ + "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "85ed9def2ea9228", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3402,14 +3497,14 @@ "async def cq(q:str): return f'got {q}'\n", "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" - ], - "id": "85ed9def2ea9228" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "fea470bc5d427298", + "metadata": {}, "outputs": [], - "execution_count": null, "source": [ "@rt('/sleepy')\n", "@cancel_on_disconnect(timeout=0.01)\n", @@ -3418,20 +3513,22 @@ " return 'nope'\n", "\n", "test_fail(lambda: cli.get('/sleepy'))" - ], - "id": "fea470bc5d427298" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "cedb36782bbf9ac" + "id": "cedb36782bbf9ac", + "metadata": {}, + "source": [ + "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a593ee4bedcabe79", + "metadata": {}, + "outputs": [], "source": [ "async def _disc(): return {'type': 'http.disconnect'}\n", "cancelled = []\n", @@ -3449,20 +3546,22 @@ "\n", "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" - ], - "id": "a593ee4bedcabe79" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## APIRouter", - "id": "4b7fc45fa7b0fb5c" + "id": "4b7fc45fa7b0fb5c", + "metadata": {}, + "source": [ + "## APIRouter" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d00984c329331d4", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class RouteFuncs:\n", @@ -3474,14 +3573,14 @@ " try: return self._funcs[name]\n", " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" - ], - "id": "d00984c329331d4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "55ff40c3aa444235", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class APIRouter:\n", @@ -3522,22 +3621,24 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" - ], - "id": "55ff40c3aa444235" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "ar = APIRouter()", - "id": "66a9e4ae6be2e6e3" + "id": "66a9e4ae6be2e6e3", + "metadata": {}, + "outputs": [], + "source": [ + "ar = APIRouter()" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1d238c64c8299999", + "metadata": {}, + "outputs": [], "source": [ "@ar(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -3560,25 +3661,25 @@ "\n", "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ], - "id": "1d238c64c8299999" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d8c66b9b4e73dd7f", + "metadata": {}, + "outputs": [], "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" - ], - "id": "d8c66b9b4e73dd7f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "41b73984941e0fca", + "metadata": {}, + "outputs": [], "source": [ "assert str(yoyo) == '/yoyo'\n", "# ensure route functions are properly discoverable on `APIRouter` and `APIRouter.rt_funcs`\n", @@ -3589,14 +3690,14 @@ "with ExceptionExpected(): ar.rt_funcs.blah()\n", "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" - ], - "id": "41b73984941e0fca" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c7bb0377e2cab336", + "metadata": {}, + "outputs": [], "source": [ "test_eq(cli.get('/hi').text, 'Hi there')\n", "test_eq(cli.post('/hi').text, 'Postal')\n", @@ -3610,35 +3711,37 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ], - "id": "c7bb0377e2cab336" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d2d806271efce6e", + "metadata": {}, + "outputs": [], "source": [ "with cli.websocket_connect('/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ], - "id": "d2d806271efce6e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "ar2 = APIRouter(\"/products\")", - "id": "609d7e4bb7e27b8d" + "id": "609d7e4bb7e27b8d", + "metadata": {}, + "outputs": [], + "source": [ + "ar2 = APIRouter(\"/products\")" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "28a985ce78b8ad79", + "metadata": {}, + "outputs": [], "source": [ "@ar2(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -3659,25 +3762,25 @@ "\n", "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ], - "id": "28a985ce78b8ad79" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a8c95ca9b68b2b66", + "metadata": {}, + "outputs": [], "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" - ], - "id": "a8c95ca9b68b2b66" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f2c612c7d9ee1b7a", + "metadata": {}, + "outputs": [], "source": [ "assert str(yoyo) == '/products/yoyo'\n", "assert ar2.prefix == '/products'\n", @@ -3687,14 +3790,14 @@ "with ExceptionExpected(): ar2.blah()\n", "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" - ], - "id": "f2c612c7d9ee1b7a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6884d7d5176d7c24", + "metadata": {}, + "outputs": [], "source": [ "test_eq(cli.get('/products/hi').text, 'Hi there')\n", "test_eq(cli.post('/products/hi').text, 'Postal')\n", @@ -3706,38 +3809,38 @@ "\n", "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" - ], - "id": "6884d7d5176d7c24" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f13043550a77dacc", + "metadata": {}, + "outputs": [], "source": [ "with cli.websocket_connect('/products/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ], - "id": "f13043550a77dacc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1ea2020dd58c30df", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" - ], - "id": "1ea2020dd58c30df" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d3019c79770fdff", + "metadata": {}, + "outputs": [], "source": [ "@ar.get\n", "def hi2(): return 'Hi there'\n", @@ -3752,28 +3855,32 @@ "def _(): return 'Hi there'\n", "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" - ], - "id": "d3019c79770fdff" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Extras", - "id": "7e71158e148dcc26" + "id": "7e71158e148dcc26", + "metadata": {}, + "source": [ + "## Extras" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "bba4b748750fb361" + "id": "bba4b748750fb361", + "metadata": {}, + "outputs": [], + "source": [ + "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e645895044c0f961", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def cookie(key: str, value=\"\", max_age=None, expires=None, path=\"/\", domain=None, secure=False, httponly=False, samesite=\"lax\",):\n", @@ -3791,14 +3898,14 @@ " cookie[key][\"samesite\"] = samesite\n", " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" - ], - "id": "e645895044c0f961" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4a83a50a23d2c67c", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/setcookie\")\n", "def get(req): return cookie('now', datetime.now())\n", @@ -3809,28 +3916,28 @@ "print(cli.get('/setcookie').text)\n", "time.sleep(0.01)\n", "cli.get('/getcookie').text" - ], - "id": "4a83a50a23d2c67c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c094b01b787efa18", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def reg_re_param(m, s):\n", " \"Register a Starlette URL convertor named `m` matching regex `s`\"\n", " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" - ], - "id": "c094b01b787efa18" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6747ee1f8fa10ddb", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "# Starlette doesn't have the '?', so it chomps the whole remaining URL\n", @@ -3843,14 +3950,14 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`\"\n", " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" - ], - "id": "6747ee1f8fa10ddb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "abd47f73e0b10c81", + "metadata": {}, + "outputs": [], "source": [ "reg_re_param(\"imgext\", \"ico|gif|jpg|jpeg|webm|pdf\")\n", "\n", @@ -3858,25 +3965,25 @@ "def get(fn:str, path:str, ext:str): return f\"Getting {fn}.{ext} from /{path}\"\n", "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" - ], - "id": "abd47f73e0b10c81" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5e67b1acf340c9de", + "metadata": {}, + "outputs": [], "source": [ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" - ], - "id": "5e67b1acf340c9de" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f821491210017e95", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -3884,25 +3991,25 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')\"\n", " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" - ], - "id": "f821491210017e95" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "572467faf76ad23e", + "metadata": {}, + "outputs": [], "source": [ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" - ], - "id": "572467faf76ad23e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "392f9abb139bb38f", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class StaticNoCache(StaticFiles):\n", @@ -3911,14 +4018,14 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" - ], - "id": "392f9abb139bb38f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a8094de91a8f6335", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class StaticImmutable(StaticFiles):\n", @@ -3927,20 +4034,22 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" - ], - "id": "a8094de91a8f6335" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "39634fb1dc368ff8" + "id": "39634fb1dc368ff8", + "metadata": {}, + "source": [ + "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8b150dc4b269a38e", + "metadata": {}, + "outputs": [], "source": [ "statd = Path(tempfile.mkdtemp())\n", "(statd/'app.css').write_text('body{}')\n", @@ -3948,14 +4057,14 @@ "stat_cli = TestClient(stat_app)\n", "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" - ], - "id": "8b150dc4b269a38e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7fffdd2e178e66d9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@flexicache(mtime_policy(arg=0))\n", @@ -3967,20 +4076,22 @@ " p = Path(root)/u.path.lstrip('/')\n", " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" - ], - "id": "7fffdd2e178e66d9" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "8e1f740dc7117a73" + "id": "8e1f740dc7117a73", + "metadata": {}, + "source": [ + "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3563fa848b89d640", + "metadata": {}, + "outputs": [], "source": [ "tmp = Path(tempfile.mkdtemp())\n", "(tmp/'a.css').write_text('body{}')\n", @@ -3993,26 +4104,26 @@ "(tmp/'a.css').write_text('body{color:red}')\n", "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" - ], - "id": "3563fa848b89d640" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "fc3c4a11a7f149ed", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "from functools import wraps\n", "from inspect import signature, isawaitable" - ], - "id": "fc3c4a11a7f149ed" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "dcfbe520b21c8a06", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty):\n", @@ -4024,14 +4135,14 @@ " f.__signature__ = sig.replace(parameters=new_params)\n", " f.__annotations__[name] = typ\n", " return f" - ], - "id": "dcfbe520b21c8a06" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bc3a2a5a0b908c62", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class into:\n", @@ -4054,26 +4165,30 @@ " anno = p.annotation if p.annotation is not Parameter.empty else NoneType\n", " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" - ], - "id": "bc3a2a5a0b908c62" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "fea1234b40b7ec25" + "id": "fea1234b40b7ec25", + "metadata": {}, + "source": [ + "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically." + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "33fd8a5f4eec0e22" + "id": "33fd8a5f4eec0e22", + "metadata": {}, + "source": [ + "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ebedc67f3cc6c31d", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -4082,50 +4197,54 @@ "@rt\n", "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" - ], - "id": "ebedc67f3cc6c31d" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "ab25de85060dd0ab" + "id": "ab25de85060dd0ab", + "metadata": {}, + "source": [ + "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\"." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "777b10770ccb5990", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/hello')\n", "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" - ], - "id": "777b10770ccb5990" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "ffe9179ea2cd38b3" + "id": "ffe9179ea2cd38b3", + "metadata": {}, + "source": [ + "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4297bf1971b0b158", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/hello?name=Jeremy&title=Greetings')\n", "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" - ], - "id": "4297bf1971b0b158" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e287be9995e1d974", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class MiddlewareBase:\n", @@ -4134,14 +4253,14 @@ " if scope[\"type\"] in (\"http\", \"websocket\"): return HTTPConnection(scope)\n", " await self._app(scope, receive, send)\n", " " - ], - "id": "e287be9995e1d974" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "abd3179c8f6dab84", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class FtResponse:\n", @@ -4157,14 +4276,14 @@ " if not tasks: tasks = self.background\n", " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" - ], - "id": "abd3179c8f6dab84" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4fa7901b39ba33dd", + "metadata": {}, + "outputs": [], "source": [ "@rt('/ftr')\n", "def get():\n", @@ -4177,20 +4296,22 @@ "test_eq(r.headers['location'], '/foo/1')\n", "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" - ], - "id": "4fa7901b39ba33dd" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Test on a single background task:", - "id": "f671d50f1f5b6219" + "id": "f671d50f1f5b6219", + "metadata": {}, + "source": [ + "Test on a single background task:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "dc50c01073e81407", + "metadata": {}, + "outputs": [], "source": [ "def my_slow_task():\n", " print('Starting slow task') \n", @@ -4204,34 +4325,36 @@ "\n", "test_eq(r.status_code, 200)\n", "\n" - ], - "id": "dc50c01073e81407" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Test multiple background tasks:", - "id": "64da62e3d30bf84c" + "id": "64da62e3d30bf84c", + "metadata": {}, + "source": [ + "Test multiple background tasks:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b8be74e5afbe825f", + "metadata": {}, + "outputs": [], "source": [ "def increment(amount):\n", " amount = amount/1000\n", " print(f'Sleeping for {amount}s') \n", " time.sleep(amount)\n", " print(f'Slept for {amount}s') " - ], - "id": "b8be74e5afbe825f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "afa44a40a7afa2f5", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def backgrounds():\n", @@ -4241,14 +4364,14 @@ "\n", "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" - ], - "id": "afa44a40a7afa2f5" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4d47edc592145db0", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def backgrounds2():\n", @@ -4257,14 +4380,14 @@ "\n", "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" - ], - "id": "4d47edc592145db0" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8adc71f6292c6d24", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def backgrounds3():\n", @@ -4274,14 +4397,14 @@ "r = cli.get('/backgrounds3')\n", "test_eq(r.status_code, 200)\n", "r.json()" - ], - "id": "8adc71f6292c6d24" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7ccb00230cdfb99e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def unqid(seeded=False):\n", @@ -4289,28 +4412,28 @@ " id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4()\n", " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" - ], - "id": "7ccb00230cdfb99e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e70a8dcfe4386a2e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _add_ids(s):\n", " if not isinstance(s, FT): return\n", " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" - ], - "id": "e70a8dcfe4386a2e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6a8579524fa4bb56", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4323,14 +4446,14 @@ " for o in conns.values(): await o(s)\n", " app._send = send\n", " return send" - ], - "id": "6a8579524fa4bb56" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "fcb9f273985b611e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "devtools_loc = \"/.well-known/appspecific/com.chrome.devtools.json\"\n", @@ -4341,14 +4464,14 @@ " if not uuid: uuid = str(uuid5(NAMESPACE_URL, str(path)))\n", " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" - ], - "id": "fcb9f273985b611e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3bf9cb548b4906fd", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4361,14 +4484,14 @@ " client = httpx2.AsyncClient() if asink else httpx2.Client()\n", " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" - ], - "id": "3bf9cb548b4906fd" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6942fa7012ca3ec6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4377,14 +4500,14 @@ " if not cookie or cookie == 'null': return {}\n", " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" - ], - "id": "6942fa7012ca3ec6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4e64484ff03ceb38", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4400,37 +4523,38 @@ " return self.app.decode_session(cookie)\n", "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" - ], - "id": "4e64484ff03ceb38" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "57a7b94f1c60e334", + "metadata": {}, + "outputs": [], "source": [ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" - ], - "id": "57a7b94f1c60e334" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Export -", - "id": "9bd92c051e08c337" + "id": "9bd92c051e08c337", + "metadata": {}, + "source": [ + "## Export -" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b3469b7214dd3f32", + "metadata": {}, + "outputs": [], "source": [ "#| hide\n", "import nbdev\n", "nbdev.nbdev_export()" - ], - "id": "b3469b7214dd3f32" + ] } ], "metadata": { From 232baf83e4ec1cd926429db204d6731aa6aded8b Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Wed, 21 Oct 2026 12:43:59 -0300 Subject: [PATCH 10/22] refactor: update request parameter aliases to camelCase and arbitrary. --- tests/test_alias.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_alias.py b/tests/test_alias.py index 059fe82b..8fd4138b 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -6,37 +6,37 @@ cli = TestClient(app) @rt("/query") -def get(user_id: Annotated[str, Alias("user-id")]): return f"q:{user_id}" +def get(user_id: Annotated[str, Alias("userId")]): return f"q:{user_id}" @rt("/form", methods=["post"]) -def post(user_id: Annotated[str, Alias("user-id")]): return f"f:{user_id}" +def post(user_id: Annotated[str, Alias("userId")]): return f"f:{user_id}" -@rt("/path/{item_id}") -def get(item_id: Annotated[int, Alias("item_id")]): return f"p:{item_id}" +@rt("/path/{itemId}") +def get(item_id: Annotated[int, Alias("itemId")]): return f"p:{item_id}" @rt("/header") -def get(x_api_key: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key}" +def get(x_api_key_hdr: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key_hdr}" @rt("/cookie") -def get(req, session_id: Annotated[str, Alias("session-id")]): return f"c:{session_id}" +def get(req, sid_cookie: Annotated[str, Alias("session-id")]): return f"c:{sid_cookie}" @rt("/int") -def get(count: Annotated[int, Alias("the-count")]): return f"i:{count + 1}" +def get(count: Annotated[int, Alias("theCount")]): return f"i:{count + 1}" @rt("/plain") def get(user_id: str): return f"plain:{user_id}" @rt("/missing") -def get(user_id: Annotated[str, Alias("user-id")]): return user_id +def get(user_id: Annotated[str, Alias("userId")]): return user_id -def test_query_alias(): assert cli.get('/query?user-id=abc').text == 'q:abc' -def test_form_alias(): assert cli.post('/form', data={'user-id': 'abc'}).text == 'f:abc' +def test_query_alias(): assert cli.get('/query?userId=abc').text == 'q:abc' +def test_form_alias(): assert cli.post('/form', data={'userId': 'abc'}).text == 'f:abc' def test_path_alias(): assert cli.get('/path/7').text == 'p:7' def test_header_alias(): assert cli.get('/header', headers={'X-Api-Key': 'k1'}).text == 'h:k1' def test_cookie_alias(): assert cli.get('/cookie', cookies={'session-id': 's1'}).text == 'c:s1' -def test_int_alias(): assert cli.get('/int?the-count=4').text == 'i:5' -def test_plain_unchanged(): assert cli.get('/plain?user_id=z').text == 'plain:z' -def test_snake_name_not_used_for_alias(): assert cli.get('/query?user_id=abc').status_code == 400 +def test_int_alias(): assert cli.get('/int?theCount=4').text == 'i:5' +def test_plain_unchanged(): assert cli.get('/plain?user-id=z').text == 'plain:z' +def test_alias_is_case_sensitive(): assert cli.get('/query?userID=abc').status_code == 400 def test_missing_message_uses_alias(): r = cli.get('/missing') - assert r.status_code == 400 and 'Missing required field: user-id' in r.text + assert r.status_code == 400 and 'Missing required field: userId' in r.text From 0fb8fc9203c66f7fef31e88acb30d3d4e688a542 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 15:57:07 -0300 Subject: [PATCH 11/22] feat: introduce Alias class for handling request parameter aliases. close #937 --- fasthtml/_modidx.py | 1 + fasthtml/core.py | 45 +- nbs/api/00_core.ipynb | 2971 ++++++++++++++++------------------------- 3 files changed, 1168 insertions(+), 1849 deletions(-) diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 181363e6..6210ee3d 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -30,6 +30,7 @@ 'fasthtml.core.APIRouter._wrap_func': ('api/core.html#apirouter._wrap_func', 'fasthtml/core.py'), 'fasthtml.core.APIRouter.to_app': ('api/core.html#apirouter.to_app', 'fasthtml/core.py'), 'fasthtml.core.APIRouter.ws': ('api/core.html#apirouter.ws', 'fasthtml/core.py'), + 'fasthtml.core.Alias': ('api/core.html#alias', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn': ('api/core.html#apireturn', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn.__bool__': ('api/core.html#apireturn.__bool__', 'fasthtml/core.py'), 'fasthtml.core.ApiReturn.__call__': ('api/core.html#apireturn.__call__', 'fasthtml/core.py'), diff --git a/fasthtml/core.py b/fasthtml/core.py index 91649939..d7ac66f3 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -18,7 +18,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. -Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: +Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: req, ws the Request / WebSocket connection sess session dict @@ -48,11 +48,11 @@ class Cards: ... # class route group: each verb-named method (get, patch, . __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', - 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', - 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', - 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', 'RouteFuncs', - 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', - 'MiddlewareBase', 'FtResponse', 'unqid'] + 'JSONResponse', 'Alias', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', + 'flat_tuple', 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', + 'FastHTML', 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', + 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', + 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] # %% ../nbs/api/00_core.ipynb #23503b9e import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess @@ -64,7 +64,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from fastcore.style import S from types import UnionType, SimpleNamespace as ns, GenericAlias -from typing import get_args, get_origin, Union, Mapping, List, Any, Callable +from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated from datetime import datetime,date from dataclasses import dataclass from inspect import Parameter,get_annotations @@ -265,8 +265,19 @@ def _default(o): return list(o) if is_listy(o) else str(o) # %% ../nbs/api/00_core.ipynb #5fa96e3a +@dataclass(frozen=True) +class Alias: + "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" + name: str + async def _find_p(conn, data, hdrs, arg:str, p:Parameter): "In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)" + alias = None + if get_origin(p.annotation) is Annotated: + anno, *meta = get_args(p.annotation) + p = p.replace(annotation=anno) + alias = next((m.name for m in meta if isinstance(m, Alias)), None) + key = alias or arg anno = p.annotation # Special annotation types if isinstance(anno, type) and not isinstance(anno, GenericAlias): @@ -276,7 +287,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if issubclass(anno, State): return conn.scope['app'].state if anno is dict: return data if _is_body(anno): - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) return await _from_body(conn, p, data) if (msg := _check_anno(arg, anno)): return warn(msg) # Special param names with no annotations @@ -297,16 +308,16 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) return None # Not a special name or a special annotation - res = conn.path_params.get(arg, None) - if res in (empty,None): res = conn.cookies.get(arg, None) - if res in (empty,None): res = _get_header(hdrs, snake2hyphens(arg)) - if res in (empty,None): res = _formitem(conn.query_params, arg) - if res in (empty,None): res = _formitem(data, arg) - if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, arg) # htmx v4 nests form data under `values` + res = conn.path_params.get(key, None) + if res in (empty,None): res = conn.cookies.get(key, None) + if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key)) + if res in (empty,None): res = _formitem(conn.query_params, key) + if res in (empty,None): res = _formitem(data, key) + if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` if res in (empty,None): if p.default is empty: - if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") - raise ValueError(f"Missing required field: {arg}") + if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {key}") + raise ValueError(f"Missing required field: {key}") res = p.default try: return _fix_anno(anno, res) except ValueError as e: @@ -627,7 +638,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] if surreal: hdrs = [surrsrc,scopesrc] + hdrs - if htmx and htmx4: + if htmx and htmx4: # metaCharacter="-" makes htmx4 use dashes instead of colons meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"})) hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index da1ece4d..ea38be7c 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1,28 +1,25 @@ { "cells": [ { - "cell_type": "code", - "execution_count": null, - "id": "fa505c58", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "#| default_exp core" - ] + "execution_count": null, + "source": "#| default_exp core", + "id": "3f786c1dd3e6d8db" }, { - "cell_type": "markdown", - "id": "2161d9b5", "metadata": {}, + "cell_type": "markdown", "source": [ "# Core\n", "> The `FastHTML` subclass of `Starlette`." - ] + ], + "id": "d4f4d2a5ada81079" }, { - "cell_type": "markdown", - "id": "1f8f308f", "metadata": {}, + "cell_type": "markdown", "source": [ "#| export\n", "Create an app with `app = FastHTML()`. The `@rt` decorator adds routes (`rt = app.route`), inferring what it can from the function:\n", @@ -43,7 +40,7 @@ "\n", "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", "\n", - "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", + "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", "\n", " req, ws the Request / WebSocket connection\n", " sess session dict\n", @@ -64,30 +61,26 @@ "`before`/`after` functions (app-level, with `Beforeware` skip patterns, or per-route `before=`) get the same param injection as handlers. A `before` returning a value responds with it and skips the handler (the usual auth-guard pattern).\n", "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." - ] + ], + "id": "a20bc9c5cf33ca9d" }, { - "cell_type": "markdown", - "id": "46e2e6e8", "metadata": {}, - "source": [ - "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/)." - ] + "cell_type": "markdown", + "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", + "id": "f25b8cebe2ad04da" }, { - "cell_type": "markdown", - "id": "568b20cd", "metadata": {}, - "source": [ - "## Imports and utils" - ] + "cell_type": "markdown", + "source": "## Imports and utils", + "id": "13be9a37727633e1" }, { - "cell_type": "code", - "execution_count": null, - "id": "23503b9e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess\n", @@ -99,7 +92,7 @@ "from fastcore.style import S\n", "\n", "from types import UnionType, SimpleNamespace as ns, GenericAlias\n", - "from typing import get_args, get_origin, Union, Mapping, List, Any, Callable\n", + "from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated\n", "from datetime import datetime,date\n", "from dataclasses import dataclass\n", "from inspect import Parameter,get_annotations\n", @@ -116,14 +109,14 @@ "\n", "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" - ] + ], + "id": "c5ce68a315f5c605" }, { - "cell_type": "code", - "execution_count": null, - "id": "7f5d0a72", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "import time, tempfile\n", "\n", @@ -137,125 +130,84 @@ "from fastcore.test import *\n", "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" - ] + ], + "id": "f0f6390fdbedc734" }, { - "cell_type": "code", - "execution_count": null, - "id": "19d3f2a7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _params(f): return signature_ex(f, True).parameters\n", "\n", "empty = Parameter.empty" - ] + ], + "id": "cc627854637f64be" }, { - "cell_type": "markdown", - "id": "19ffa7e0", "metadata": {}, - "source": [ - "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern." - ] + "cell_type": "markdown", + "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", + "id": "bbe438ea713de1a8" }, { - "cell_type": "code", - "execution_count": null, - "id": "e68a76c9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def parsed_date(s:str):\n", " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" - ] + ], + "id": "18295066a96407ae" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5331a3e7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "datetime.datetime(2026, 10, 30, 14, 0)" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "parsed_date('2pm')" - ] + "source": "parsed_date('2pm')", + "id": "aad5601af0e355af" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "c40c9071", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "isinstance(date.fromtimestamp(0), date)" - ] + "source": "isinstance(date.fromtimestamp(0), date)", + "id": "4b914cd2d2f32f6c" }, { - "cell_type": "code", - "execution_count": null, - "id": "7c820373", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def snake2hyphens(s:str):\n", " \"Convert `s` from snake case to hyphenated and capitalised\"\n", " s = snake2camel(s)\n", " return camel2words(s, '-')" - ] + ], + "id": "c15e1d6a7e2b7e4a" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "442a5aac", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Snake-Case'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "snake2hyphens(\"snake_case\")" - ] + "source": "snake2hyphens(\"snake_case\")", + "id": "b7f6b0483a096d32" }, { - "cell_type": "code", - "execution_count": null, - "id": "25f3b8f8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "htmx_hdrs = dict(\n", @@ -287,14 +239,14 @@ "def _get_htmx(h):\n", " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" - ] + ], + "id": "4010a03d4a4cff88" }, { - "cell_type": "code", - "execution_count": null, - "id": "5b4b5d95", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def test_request(url: str='/', headers: dict=None, method: str='get')->Request:\n", " if headers is None: headers={}\n", @@ -302,66 +254,53 @@ " client=('127.0.0.1', 8000), server=('127.0.0.1', 8000))\n", " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" - ] + ], + "id": "f89046ee480e36b1" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "36e2cac0", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "HtmxHeaders(boosted=None, current_url=None, history_restore_request=None, prompt=None, request='1', request_type=None, source=None, target=None, trigger_name=None, trigger=None)" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" - ] + ], + "id": "dd1139d65bb86dfb" }, { - "cell_type": "code", - "execution_count": null, - "id": "1d53e8e7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" - ] + ], + "id": "db6dfa62177494e" }, { - "cell_type": "markdown", - "id": "ef18f76b", "metadata": {}, - "source": [ - "## Request and response" - ] + "cell_type": "markdown", + "source": "## Request and response", + "id": "565092da5fd23f0" }, { - "cell_type": "code", - "execution_count": null, - "id": "5ab74473", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "fh_cfg = AttrDict(indent=True)" - ] + ], + "id": "65f97cf4a57869b1" }, { - "cell_type": "code", - "execution_count": null, - "id": "88d12e68", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "_special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'}\n", @@ -370,14 +309,14 @@ " \"Check for common annotation issues; returns warning string or None\"\n", " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" - ] + ], + "id": "ff28371b0eaa94c" }, { - "cell_type": "code", - "execution_count": null, - "id": "0afb520c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _fix_anno(t, o):\n", @@ -391,14 +330,14 @@ " if isinstance(t, type) and issubclass(t, (list,tuple)): return None\n", " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" - ] + ], + "id": "116cdc2ba4c1da1d" }, { - "cell_type": "code", - "execution_count": null, - "id": "95b1f5c9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_eq(_fix_anno(Union[str,None], 'a'), 'a')\n", "test_eq(_fix_anno(float, 0.9), 0.9)\n", @@ -412,14 +351,14 @@ "test_eq(_fix_anno(tuple, ['1','2']), None)\n", "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" - ] + ], + "id": "398a7dad7f5f67d4" }, { - "cell_type": "code", - "execution_count": null, - "id": "c58ccadb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _form_arg(k, v, d):\n", @@ -430,76 +369,63 @@ " anno = d.get(k, None)\n", " if not anno: return v\n", " return _fix_anno(anno, v)" - ] + ], + "id": "aa6c1ec4aa689b59" }, { - "cell_type": "code", - "execution_count": null, - "id": "59757d76", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "d = dict(k=int, l=List[int])\n", "test_eq(_form_arg('k', \"1\", d), 1)\n", "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" - ] + ], + "id": "a077ee4e079d745a" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fc04751", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@dataclass\n", "class HttpHeader:\n", " \"A response header as a k,v pair\"\n", " k:str;v:str" - ] + ], + "id": "f597b0ffe2803a1e" }, { - "cell_type": "code", - "execution_count": null, - "id": "94e18161", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()\n", "\n", "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" - ] + ], + "id": "647812de36af6567" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "592d6c8e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'HX-Trigger-After-Settle'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "_to_htmx_header('trigger_after_settle')" - ] + "source": "_to_htmx_header('trigger_after_settle')", + "id": "65f8b52017efe8b1" }, { - "cell_type": "code", - "execution_count": null, - "id": "f6a2e62e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@use_kwargs_dict(**htmx_resps)\n", @@ -507,105 +433,70 @@ " \"HTMX response headers\"\n", " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" - ] + ], + "id": "f5c7bc122bcf3a4e" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "e89857eb", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "HttpHeader(k='HX-Trigger-After-Settle', v='hi')" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "HtmxResponseHeaders(trigger_after_settle='hi')" - ] + "source": "HtmxResponseHeaders(trigger_after_settle='hi')", + "id": "aa2b13597da91281" }, { - "cell_type": "code", - "execution_count": null, - "id": "56cc589f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _annotations(anno):\n", " \"Same as `get_annotations`, but also works on namedtuples\"\n", " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" - ] + ], + "id": "c48b1894c429b354" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5453a684", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'a': int, 'b': str}" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@dataclass\n", "class Foo: a:int; b:str\n", "_annotations(Foo)" - ] + ], + "id": "17a8db0eaed1e51c" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bcad3a5a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'x': str, 'y': str}" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" - ] + ], + "id": "44d1591fde10dece" }, { - "cell_type": "code", - "execution_count": null, - "id": "cb4ed4aa", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" - ] + ], + "id": "98af78b583ae6ab" }, { - "cell_type": "code", - "execution_count": null, - "id": "ffdde66f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _formitem(form, k):\n", @@ -614,53 +505,53 @@ " if isinstance(form, dict): return form.get(k)\n", " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" - ] + ], + "id": "8a415bf0ea6f622f" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fe74444", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def form2dict(form: FormData) -> dict:\n", " \"Convert starlette form data to a dict\"\n", " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" - ] + ], + "id": "ee1ad0926149acdf" }, { - "cell_type": "code", - "execution_count": null, - "id": "cf80ea34", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "d = [('a',1),('a',2),('b',0)]\n", "fd = FormData(d)\n", "res = form2dict(fd)\n", "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" - ] + ], + "id": "6d67eb875fd651aa" }, { - "cell_type": "code", - "execution_count": null, - "id": "8899bc0a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "DEF_MAXPART = 100*1024*1024" - ] + ], + "id": "2b75aef99f3233e" }, { - "cell_type": "code", - "execution_count": null, - "id": "42c9cea0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def parse_form(req: Request) -> FormData:\n", @@ -680,50 +571,48 @@ " if not isinstance(res, dict): raise HTTPException(400, \"JSON body must be an object\")\n", " return res\n", " return await req.form(max_part_size=maxpart)" - ] + ], + "id": "6303b3d2a7ffa4ad" }, { - "cell_type": "markdown", - "id": "437263a3", "metadata": {}, - "source": [ - "Use the special `__from_request__` class method for custom creation:" - ] + "cell_type": "markdown", + "source": "Use the special `__from_request__` class method for custom creation:", + "id": "b72a46be2e6cc9ef" }, { - "cell_type": "code", - "execution_count": null, - "id": "8235f17f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "class Foo(BasicRepr):\n", " def __init__(self, d): self.d = d\n", "\n", " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" - ] + ], + "id": "7040a70ddc39d9f6" }, { - "cell_type": "code", - "execution_count": null, - "id": "a4873835", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "p = Parameter('a', Parameter.POSITIONAL_OR_KEYWORD, annotation=Foo)\n", "\n", "req = test_request(method='POST')\n", "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" - ] + ], + "id": "916fc4b111857f80" }, { - "cell_type": "code", - "execution_count": null, - "id": "0caedd04", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _from_body(conn, p, data):\n", @@ -740,14 +629,14 @@ " if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data\n", " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" - ] + ], + "id": "b6777d96f0291d58" }, { - "cell_type": "code", - "execution_count": null, - "id": "88b6da3f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class ApiReturn:\n", @@ -757,22 +646,20 @@ " def __init__(self, isapi=False): self.isapi = isapi\n", " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" - ] + ], + "id": "f85d3ee49247287c" }, { - "cell_type": "markdown", - "id": "abfc3145", "metadata": {}, - "source": [ - "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints." - ] + "cell_type": "markdown", + "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", + "id": "4be9f09a003c0045" }, { - "cell_type": "code", - "execution_count": null, - "id": "7cc39ba9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class JSONResponse(JSONResponseOrig):\n", @@ -781,22 +668,14 @@ " def _default(o): return list(o) if is_listy(o) else str(o)\n", " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" - ] + ], + "id": "a1b7f0b5c6ce8ac0" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "2a8b10f4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'k': 'value1', 'v': 'value3'}\n" - ] - } - ], "source": [ "async def f(req):\n", " def _f(p:HttpHeader): ...\n", @@ -810,14 +689,14 @@ "d = dict(k='value1',v=['value2','value3'])\n", "response = client.post('/', data=d)\n", "print(response.json())" - ] + ], + "id": "b41d50ec84d3abd5" }, { - "cell_type": "code", - "execution_count": null, - "id": "301e06eb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# An empty `application/json` body yields `{}` (like the empty-multipart guard), not a 500\n", "async def _empty_json(req): return JSONResponse(form2dict(await parse_form(req)))\n", @@ -827,113 +706,80 @@ "test_eq(res.json(), {})\n", "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" - ] + ], + "id": "97eeb77679bd2f8b" }, { - "cell_type": "markdown", - "id": "18a9e366", "metadata": {}, - "source": [ - "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:" - ] + "cell_type": "markdown", + "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", + "id": "56936fa2386c2bcc" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "28bfbbc1", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Did not find CR at end of boundary (5)\n" - ] - }, - { - "data": { - "text/plain": [ - "'Invalid multipart data.'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "bad = b'--xyzXX\\r\\nContent-Disposition: form-data; name=\"a\"\\r\\n\\r\\n1\\r\\n--xyz--\\r\\n'\n", "res = client.post('/', content=bad, headers={'content-type':'multipart/form-data; boundary=xyz'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ] + ], + "id": "ba168fb917abf529" }, { - "cell_type": "markdown", - "id": "6103784b", "metadata": {}, - "source": [ - "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:" - ] + "cell_type": "markdown", + "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", + "id": "48724c70b61d9fbf" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "2aaa8fbe", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Invalid JSON body'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "res = client.post('/', json=[1,2])\n", "test_eq(res.status_code, 400)\n", "res = client.post('/', content=b'not json', headers={'content-type':'application/json'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ] + ], + "id": "5ec7b2f219030178" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "9246153f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "\"['1', '2']\"" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "async def f(req): return Response(str(req.query_params.getlist('x')))\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" - ] + ], + "id": "6f68bc0070827210" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fa96e3a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", + "@dataclass(frozen=True)\n", + "class Alias:\n", + " \"Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`\"\n", + " name: str\n", + "\n", "async def _find_p(conn, data, hdrs, arg:str, p:Parameter):\n", " \"In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)\"\n", + " alias = None\n", + " if get_origin(p.annotation) is Annotated:\n", + " anno, *meta = get_args(p.annotation)\n", + " p = p.replace(annotation=anno)\n", + " alias = next((m.name for m in meta if isinstance(m, Alias)), None)\n", + " key = alias or arg\n", " anno = p.annotation\n", " # Special annotation types\n", " if isinstance(anno, type) and not isinstance(anno, GenericAlias):\n", @@ -964,85 +810,66 @@ " if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower())\n", " return None\n", " # Not a special name or a special annotation\n", - " res = conn.path_params.get(arg, None)\n", - " if res in (empty,None): res = conn.cookies.get(arg, None)\n", - " if res in (empty,None): res = _get_header(hdrs, snake2hyphens(arg))\n", - " if res in (empty,None): res = _formitem(conn.query_params, arg)\n", - " if res in (empty,None): res = _formitem(data, arg)\n", - " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, arg) # htmx v4 nests form data under `values`\n", + " res = conn.path_params.get(key, None)\n", + " if res in (empty,None): res = conn.cookies.get(key, None)\n", + " if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key))\n", + " if res in (empty,None): res = _formitem(conn.query_params, key)\n", + " if res in (empty,None): res = _formitem(data, key)\n", + " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values`\n", " if res in (empty,None):\n", " if p.default is empty:\n", - " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {arg}\")\n", - " raise ValueError(f\"Missing required field: {arg}\")\n", + " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {key}\")\n", + " raise ValueError(f\"Missing required field: {key}\")\n", " res = p.default\n", " try: return _fix_anno(anno, res)\n", " except ValueError as e:\n", " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" - ] + ], + "id": "8aa1c500837660bd" }, { - "cell_type": "code", - "execution_count": null, - "id": "bf42edad", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _find_ps(conn, data, hdrs, params):\n", " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" - ] + ], + "id": "e4b1d85a2a5d2885" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "0ac66d98", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Foo(d={'a': 1})" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" - ] + ], + "id": "f34126d1cde29f61" }, { - "cell_type": "code", - "execution_count": null, - "id": "090f1f0f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wrap_req(req, params):\n", " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" - ] + ], + "id": "a2c22342a507911" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "26df5218", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "ok body=19\n" - ] - } - ], "source": [ "def g(test_val:int=0, item:HttpHeader=None, htmx:HtmxHeaders=None, api:ApiReturn=None): ...\n", "\n", @@ -1055,22 +882,14 @@ "r = client.post('/', data=dict(test_val='21', k='a', v='b'), headers={'HX-Request': '1'})\n", "test_eq(r.status_code, 200)\n", "print(r.text)" - ] + ], + "id": "151e1fdb275740e5" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bf945ee8", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3'), 'state': }\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader, state): ...\n", "\n", @@ -1081,22 +900,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ] + ], + "id": "6ae32895f4eb371d" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "a3ded5ec", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3')}\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader): ...\n", "\n", @@ -1107,25 +918,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ] + ], + "id": "4507d0395e7f6121" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "0e7601fe", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "\"Bar(accept='application/json')\"" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "class Bar(BasicRepr):\n", " @classmethod\n", @@ -1140,38 +940,26 @@ "\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" - ] + ], + "id": "a074d981ec0d03d" }, { - "cell_type": "markdown", - "id": "34066c89", "metadata": {}, - "source": [ - "**Missing Request Params**" - ] + "cell_type": "markdown", + "source": "**Missing Request Params**", + "id": "db378609f8ab795b" }, { - "cell_type": "markdown", - "id": "06259883", "metadata": {}, - "source": [ - "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request." - ] + "cell_type": "markdown", + "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", + "id": "200212e4df82f398" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "c369a89c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'req': , 'this': , 'a': ''}\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str=''): ...\n", "\n", @@ -1182,30 +970,20 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ] + ], + "id": "1d2602318cb04a61" }, { - "cell_type": "markdown", - "id": "35b68f4f", "metadata": {}, - "source": [ - "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`." - ] + "cell_type": "markdown", + "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", + "id": "e2aa1e7e7629dd48" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b33b43a7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Missing required field: a\n" - ] - } - ], "source": [ "def g(req, this:Starlette, a:str): ...\n", "\n", @@ -1216,14 +994,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ] + ], + "id": "29e40d3211ed08bd" }, { - "cell_type": "code", - "execution_count": null, - "id": "7a661bfa", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def flat_xt(lst):\n", @@ -1234,85 +1012,83 @@ " if isinstance(item, (list,tuple)): result.extend(item)\n", " else: result.append(item)\n", " return tuple(result)" - ] + ], + "id": "2c3aa048ffeb42c8" }, { - "cell_type": "code", - "execution_count": null, - "id": "2ee5adf1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "x = ft('a',1)\n", "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" - ] + ], + "id": "97f3c80aa48ff101" }, { - "cell_type": "code", - "execution_count": null, - "id": "aacff5ac", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class Beforeware:\n", " \"Wrap a `before` function with `skip` path patterns that bypass it\"\n", " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" - ] + ], + "id": "8e9006527c670be6" }, { - "cell_type": "code", - "execution_count": null, - "id": "78c3c357", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" - ] + ], + "id": "62836c8032499267" }, { - "cell_type": "code", - "execution_count": null, - "id": "c995850c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" - ] + ], + "id": "93d5b5461c789cc0" }, { - "cell_type": "markdown", - "id": "079a3215", "metadata": {}, - "source": [ - "## Websockets / SSE" - ] + "cell_type": "markdown", + "source": "## Websockets / SSE", + "id": "4560ed04c182ea9f" }, { - "cell_type": "code", - "execution_count": null, - "id": "ad0f0e87", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wrap_ws(ws, data, params):\n", " raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers`\n", " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" - ] + ], + "id": "1f84e03104d49a98" }, { - "cell_type": "code", - "execution_count": null, - "id": "dcc15129", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _send_ws(ws, resp):\n", @@ -1341,14 +1117,14 @@ " if disconn: cls.on_disconnect = _disconnect\n", " cls.on_receive = _recv\n", " return cls" - ] + ], + "id": "71be83675c9f169" }, { - "cell_type": "code", - "execution_count": null, - "id": "983bcfe2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def on_receive(msg:str): return f\"Message text was: {msg}\"\n", "c = _ws_endp(on_receive)\n", @@ -1357,14 +1133,14 @@ " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" - ] + ], + "id": "5e04620f390af7d7" }, { - "cell_type": "code", - "execution_count": null, - "id": "bda2e425", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def on_receive(hx_trigger:str): return f\"trigger: {hx_trigger}\"\n", "cli = TestClient(Starlette(routes=[WebSocketRoute('/', _ws_endp(on_receive))]))\n", @@ -1372,27 +1148,27 @@ " ws.send_text(json.dumps({\"HEADERS\": {\"Hx-Trigger\": \"my-btn\"}}))\n", " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" - ] + ], + "id": "680c20728829fbf5" }, { - "cell_type": "code", - "execution_count": null, - "id": "5b0e7677", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def EventStream(s):\n", " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" - ] + ], + "id": "495ade814b13e067" }, { - "cell_type": "code", - "execution_count": null, - "id": "0dd0a414", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def signal_shutdown():\n", @@ -1405,60 +1181,58 @@ " self.force_exit = True\n", " self._orig_handle_exit(*args, **kwargs)\n", " return event" - ] + ], + "id": "27bdb0952b3e1455" }, { - "cell_type": "markdown", - "id": "496cd78f", "metadata": {}, - "source": [ - "## Routing and application" - ] + "cell_type": "markdown", + "source": "## Routing and application", + "id": "6f0a1d02da42b1ee" }, { - "cell_type": "code", - "execution_count": null, - "id": "03f4c639", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def uri(_arg, **kwargs):\n", " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" - ] + ], + "id": "c52ce0229dbce13" }, { - "cell_type": "code", - "execution_count": null, - "id": "bdb2ac14", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def decode_uri(s):\n", " \"Decode a URI created by `uri()` back into argument and keyword dict\"\n", " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" - ] + ], + "id": "8b8fec82b5e03a38" }, { - "cell_type": "code", - "execution_count": null, - "id": "b80ce139", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "from starlette.convertors import StringConvertor" - ] + ], + "id": "497dcb292b6854f8" }, { - "cell_type": "code", - "execution_count": null, - "id": "a27adc1e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "StringConvertor.regex = \"[^/]*\" # `+` replaced with `*`\n", @@ -1469,28 +1243,28 @@ " assert \"/\" not in value, \"May not contain path separators\"\n", " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" - ] + ], + "id": "79e4255c6e39b9e3" }, { - "cell_type": "code", - "execution_count": null, - "id": "46614165", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", "def url_path_for(self:HTTPConnection, name: str, **path_params):\n", " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" - ] + ], + "id": "ac7b17844f431e8d" }, { - "cell_type": "code", - "execution_count": null, - "id": "c1707d59", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "_verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href')\n", @@ -1517,25 +1291,25 @@ " \"Convert response to XML string with target URL resolution\"\n", " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" - ] + ], + "id": "70267e21152a6055" }, { - "cell_type": "code", - "execution_count": null, - "id": "f1e3ed2d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" - ] + ], + "id": "c6c76c290faa38b9" }, { - "cell_type": "code", - "execution_count": null, - "id": "a407dc0e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def flat_tuple(o):\n", @@ -1547,27 +1321,27 @@ " if isinstance(item, _iter_typs): result.extend(list(item))\n", " else: result.append(item)\n", " return tuple(result)" - ] + ], + "id": "93fa1064564563cb" }, { - "cell_type": "code", - "execution_count": null, - "id": "d36402e6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def noop_body(c, req):\n", " \"Default Body wrap function which just returns the content\"\n", " return c" - ] + ], + "id": "e5b7576e77e33db9" }, { - "cell_type": "code", - "execution_count": null, - "id": "7f49728d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def respond(req, heads, bdy):\n", @@ -1577,37 +1351,37 @@ " bw_args = (bdy, req) if len(params)>1 else (bdy,)\n", " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" - ] + ], + "id": "61fb5baad79b5193" }, { - "cell_type": "markdown", - "id": "cfae0a56", "metadata": {}, + "cell_type": "markdown", "source": [ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" - ] + ], + "id": "25c200a3ee73995a" }, { - "cell_type": "code", - "execution_count": null, - "id": "b2007479", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def is_full_page(req, resp):\n", " \"Check if response should be rendered as full page or fragment\"\n", " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" - ] + ], + "id": "eaee9ec56b374b67" }, { - "cell_type": "code", - "execution_count": null, - "id": "358badec", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _part_resp(req, resp):\n", @@ -1625,14 +1399,14 @@ " resp = tuple(resp)\n", " if len(resp)==1: resp = resp[0]\n", " return resp,kw" - ] + ], + "id": "35bfa13b8e450d11" }, { - "cell_type": "code", - "execution_count": null, - "id": "da449a7b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _canonical(req):\n", @@ -1649,27 +1423,27 @@ " title = [] if any(getattr(o, 'tag', '')=='title' for o in heads) else [Title(req.app.title)]\n", " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" - ] + ], + "id": "b082dc01906b3181" }, { - "cell_type": "code", - "execution_count": null, - "id": "775fb66b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _is_ft_resp(resp):\n", " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" - ] + ], + "id": "996138cef8668ae" }, { - "cell_type": "code", - "execution_count": null, - "id": "968d9245", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _resp(req, resp, cls=empty, status_code=200):\n", @@ -1690,14 +1464,14 @@ " resp = str(resp)\n", " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" - ] + ], + "id": "161df71b1b684993" }, { - "cell_type": "code", - "execution_count": null, - "id": "eac44461", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class Redirect:\n", @@ -1706,28 +1480,28 @@ " def __response__(self, req):\n", " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" - ] + ], + "id": "eb205cf1b819ddae" }, { - "cell_type": "code", - "execution_count": null, - "id": "1ba52822", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wrap_call(f, req, params):\n", " \"Wrap function call with request parameter injection\"\n", " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" - ] + ], + "id": "8bab4012aef733af" }, { - "cell_type": "code", - "execution_count": null, - "id": "b0d1cbbf", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "# chkstyle: skip\n", @@ -1748,40 +1522,28 @@ " \"live\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-live.min.js\",\n", " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" - ] + ], + "id": "fe59c71ad25fba98" }, { - "cell_type": "markdown", - "id": "3531273d", "metadata": {}, - "source": [ - "The FastHTML `exts` param supports the following:" - ] + "cell_type": "markdown", + "source": "The FastHTML `exts` param supports the following:", + "id": "a87a87c115d7ab5f" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "deffbaaa", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "morph head-support preload class-tools loading-states multi-swap path-deps remove-me debug ws ws4 chunked-transfer sse4 live multipart\n" - ] - } - ], - "source": [ - "print(' '.join(htmx_exts))" - ] + "source": "print(' '.join(htmx_exts))", + "id": "4f10ad35d4a74eeb" }, { - "cell_type": "code", - "execution_count": null, - "id": "60cb52ea", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", @@ -1791,14 +1553,14 @@ "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" - ] + ], + "id": "f9e3a1987936a6c5" }, { - "cell_type": "code", - "execution_count": null, - "id": "8bd78eeb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def get_key(key=None, fname='.sesskey'):\n", @@ -1809,27 +1571,27 @@ " key = str(uuid.uuid4())\n", " fname.write_text(key)\n", " return key" - ] + ], + "id": "f5d7dc946b3f66b1" }, { - "cell_type": "code", - "execution_count": null, - "id": "042666e2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _list(o):\n", " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" - ] + ], + "id": "f0a9ff0725182815" }, { - "cell_type": "code", - "execution_count": null, - "id": "d276fc71", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap):\n", @@ -1840,14 +1602,14 @@ " res = await _handle(f, req, exc)\n", " return _resp(req, res, status_code=status_code)\n", " return _f" - ] + ], + "id": "714803a8ed1ed267" }, { - "cell_type": "code", - "execution_count": null, - "id": "bc323fd4", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def qp(p:str, **kw) -> str:\n", @@ -1860,84 +1622,78 @@ " p = re.sub(r'\\{([^:}]+)(:.+?)?}', _sub, p)\n", " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" - ] + ], + "id": "94d54ea53eb972f7" }, { - "cell_type": "markdown", - "id": "e51e2dc3", "metadata": {}, - "source": [ - "`qp` adds query parameters to route path strings" - ] + "cell_type": "markdown", + "source": "`qp` adds query parameters to route path strings", + "id": "f7f245937128dba8" }, { - "cell_type": "code", - "execution_count": null, - "id": "ff82dc78", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)" - ] + "execution_count": null, + "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", + "id": "7a51473516c833ec" }, { - "cell_type": "code", - "execution_count": null, - "id": "c0836a8f", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" - ] + ], + "id": "2e810fff2275acfe" }, { - "cell_type": "markdown", - "id": "4740272f", "metadata": {}, - "source": [ - "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly." - ] + "cell_type": "markdown", + "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", + "id": "f29f68f415c1e88e" }, { - "cell_type": "code", - "execution_count": null, - "id": "50ebb1ee", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "path = '/foo/{a}/{d}/{ab:int}'\n", "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" - ] + ], + "id": "16c478cfb8b7748e" }, { - "cell_type": "code", - "execution_count": null, - "id": "f86690c4", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def def_hdrs(htmx=True, htmx4=False, surreal=True):\n", " \"Default headers for a FastHTML app\"\n", " hdrs = []\n", " if surreal: hdrs = [surrsrc,scopesrc] + hdrs\n", - " if htmx and htmx4: \n", + " if htmx and htmx4:\n", " # metaCharacter=\"-\" makes htmx4 use dashes instead of colons\n", " meta_cfg = Meta(name=\"htmx-config\", content=json.dumps({\"metaCharacter\": \"-\"}))\n", " hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs \n", " # TODO: Check if fhjsscr works with htmx4\n", " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" - ] + ], + "id": "492e4e3dead5768d" }, { - "cell_type": "code", - "execution_count": null, - "id": "2c5285ae", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])\n", @@ -1963,14 +1719,14 @@ " }\n", " };\n", "\"\"\"))\n" - ] + ], + "id": "8f79f2cd9880ad30" }, { - "cell_type": "code", - "execution_count": null, - "id": "17ced9a3", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class _LifespanCtx:\n", @@ -1998,14 +1754,14 @@ " for f in self.shutdown: await _handle(f)\n", "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" - ] + ], + "id": "6096097c1db566fa" }, { - "cell_type": "code", - "execution_count": null, - "id": "3327a1e9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class FastHTML(Starlette):\n", @@ -2045,22 +1801,20 @@ " super().__init__(debug, routes, middleware=middleware, exception_handlers=excs, lifespan=self.lifespan)\n", "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" - ] + ], + "id": "ee5d4b661700ce01" }, { - "cell_type": "markdown", - "id": "c7d4052c", "metadata": {}, - "source": [ - "### Routes" - ] + "cell_type": "markdown", + "source": "### Routes", + "id": "50b7059e3eedea1d" }, { - "cell_type": "code", - "execution_count": null, - "id": "dce68049", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class HostRoute(Route):\n", @@ -2079,22 +1833,14 @@ " def __repr__(self) -> str:\n", " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" - ] + ], + "id": "d0f727c1b16d6440" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b8823cfe", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "all passed\n" - ] - } - ], "source": [ "app = FastHTML()\n", "app.router.routes = []\n", @@ -2107,22 +1853,14 @@ "test_eq(cli.get('/', headers={'host': 'localhost'}).text, 'main page')\n", "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" - ] + ], + "id": "ec5f4a1ef1023f84" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "3da46acd", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "all passed\n" - ] - } - ], "source": [ "app.router.routes.append(HostRoute('/hello', lambda _: Response('api hello'), host='api.{parentdom}', methods=['GET'], name='api_hello'))\n", "app.router.routes.append(HostRoute('/hello', lambda _: Response('normal hello'), methods=['GET'], name='hello'))\n", @@ -2131,14 +1869,14 @@ "test_eq(cli.get('/hello', headers={'host': 'mysite.com'}).text, 'normal hello')\n", "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" - ] + ], + "id": "bebee3681051f082" }, { - "cell_type": "code", - "execution_count": null, - "id": "e0accf76", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2150,25 +1888,25 @@ " ((route.methods is None) or (set(r.methods) == set(route.methods))))]\n", " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" - ] + ], + "id": "53af25b6cce9bc6b" }, { - "cell_type": "code", - "execution_count": null, - "id": "246bd8d1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" - ] + ], + "id": "17c7250429fd19f1" }, { - "cell_type": "code", - "execution_count": null, - "id": "26b147ba", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2198,14 +1936,14 @@ " if nr: resp = nr\n", " return _resp(req, resp, sig.return_annotation)\n", " return _f" - ] + ], + "id": "6626baf208595512" }, { - "cell_type": "code", - "execution_count": null, - "id": "3818575c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2216,14 +1954,14 @@ " route.methods = ['ws']\n", " self.add_route(route)\n", " return func" - ] + ], + "id": "83e446817d470f70" }, { - "cell_type": "code", - "execution_count": null, - "id": "669e76eb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2231,28 +1969,28 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" - ] + ], + "id": "6096911dd3f83426" }, { - "cell_type": "code", - "execution_count": null, - "id": "j6ete5u68fo", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#|export\n", "@patch\n", "def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None):\n", " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" - ] + ], + "id": "36d6fa0a562a2c16" }, { - "cell_type": "code", - "execution_count": null, - "id": "919618c3", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _mk_locfunc(f, p, app=None):\n", @@ -2267,61 +2005,50 @@ " def __str__(self): return p\n", "\n", " return _lf()" - ] + ], + "id": "dd60709ea16e90e" }, { - "cell_type": "code", - "execution_count": null, - "id": "d2ecc738", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def nested_name(f):\n", " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" - ] + ], + "id": "36ec67ca52fe888e" }, { - "cell_type": "code", - "execution_count": null, - "id": "c0f13ece", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def f():\n", " def g(): ...\n", " return g" - ] + ], + "id": "de50eeca986fdb82" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b218f738", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'f_g'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "func = f()\n", "nested_name(func)" - ] + ], + "id": "14afe8a1f2663eb8" }, { - "cell_type": "code", - "execution_count": null, - "id": "daafe4fc", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2334,14 +2061,14 @@ " handler = getattr(cls, meth, None)\n", " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" - ] + ], + "id": "7bdf8a274dd92421" }, { - "cell_type": "code", - "execution_count": null, - "id": "3710e48b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _route_pn(func, path, name):\n", @@ -2367,14 +2094,14 @@ " lf = _mk_locfunc(func, p, app=self)\n", " lf.__routename__ = n\n", " return lf" - ] + ], + "id": "1a22c77950f4ee73" }, { - "cell_type": "code", - "execution_count": null, - "id": "f5cb2c2b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2385,71 +2112,47 @@ " return f(path) if callable(path) else f\n", "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" - ] + ], + "id": "8ba498d71421b1c0" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "e6ee3a86", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/foo?a=bar&b=1&b=2'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app = FastHTML()\n", "@app.get\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ] + ], + "id": "dcdc879f26db4e6" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "9b9f1f03", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/foo/bar?b=1&b=2'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@app.get('/foo/{a}')\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ] + ], + "id": "1ff1a26dded6c3b8" }, { - "cell_type": "markdown", - "id": "4ab96dc2", "metadata": {}, - "source": [ - "### Utils" - ] + "cell_type": "markdown", + "source": "### Utils", + "id": "c2edb43ac38ef019" }, { - "cell_type": "code", - "execution_count": null, - "id": "35c35a96", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -2457,14 +2160,14 @@ " \"Set the lifespan context manager for the FastHTML app\"\n", " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" - ] + ], + "id": "c4ebeca9aef8ff55" }, { - "cell_type": "code", - "execution_count": null, - "id": "3a348474", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def serve(\n", @@ -2491,22 +2194,20 @@ " link = f'http://{\"localhost\" if host==\"0.0.0.0\" else host}:{port}'\n", " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" - ] + ], + "id": "d0a02093f42266bf" }, { - "cell_type": "markdown", - "id": "428b0e9c", "metadata": {}, - "source": [ - "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them." - ] + "cell_type": "markdown", + "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", + "id": "e8cab1a72e4e2cd3" }, { - "cell_type": "code", - "execution_count": null, - "id": "91b08e9a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _spawn(n):\n", @@ -2514,22 +2215,20 @@ " ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)]\n", " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" - ] + ], + "id": "d758bc40a92109f" }, { - "cell_type": "markdown", - "id": "1129768b", "metadata": {}, - "source": [ - "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first." - ] + "cell_type": "markdown", + "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", + "id": "25f1df75b73c0566" }, { - "cell_type": "code", - "execution_count": null, - "id": "c5220a0e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "async def _wait_disconnect(req):\n", @@ -2545,22 +2244,20 @@ " finally:\n", " watch.cancel()\n", " if not work.done(): work.cancel()" - ] + ], + "id": "a0e7d14054b42734" }, { - "cell_type": "markdown", - "id": "8c6a8cb8", "metadata": {}, - "source": [ - "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled." - ] + "cell_type": "markdown", + "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", + "id": "6fa28675a503052b" }, { - "cell_type": "code", - "execution_count": null, - "id": "c5721d76", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def cancel_on_disconnect(\n", @@ -2575,14 +2272,14 @@ " c = until_disconnect(__req, f(**kw))\n", " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" - ] + ], + "id": "64c2a5739dd998e5" }, { - "cell_type": "code", - "execution_count": null, - "id": "8121968a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class Client:\n", @@ -2596,92 +2293,55 @@ " with from_thread.start_blocking_portal() as portal: return portal.call(_request)\n", "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" - ] + ], + "id": "cb229495a0755196" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "b163c933", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'test'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app = FastHTML(routes=[Route('/', lambda _: Response('test'))])\n", "cli = Client(app)\n", "\n", "cli.get('/').text" - ] + ], + "id": "1ce9426cf6938597" }, { - "cell_type": "markdown", - "id": "efc8e6db", "metadata": {}, - "source": [ - "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable." - ] + "cell_type": "markdown", + "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", + "id": "fea360b8c6c5135c" }, { - "cell_type": "markdown", - "id": "2d2633dd", "metadata": {}, - "source": [ - "## FastHTML Tests" - ] + "cell_type": "markdown", + "source": "## FastHTML Tests", + "id": "d140cd4c811c8f7f" }, { - "cell_type": "code", - "execution_count": null, - "id": "9abc3781", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "def get_cli(app): return app,TestClient(app),app.route" - ] + "execution_count": null, + "source": "def get_cli(app): return app,TestClient(app),app.route", + "id": "f507ae78ecd5da64" }, { - "cell_type": "code", - "execution_count": null, - "id": "645d8d95", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" - ] + "execution_count": null, + "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", + "id": "6fc2ddec3151fd52" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "421262a8", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[HostRoute(path='/foo', name='foo', methods=['GET', 'HEAD'], host=None)]\n" - ] - }, - { - "data": { - "text/plain": [ - "'/foo?param=value'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app,cli,rt = get_cli(FastHTML(title=\"My Custom Title\"))\n", "@app.get\n", @@ -2693,22 +2353,14 @@ "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ] + ], + "id": "65ba7460a0553aa6" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "8836b62c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[HostRoute(path='/foo', name='foo', methods=['GET', 'HEAD', 'POST'], host='test.foo'), HostRoute(path='/foo', name='foo', methods=['GET', 'HEAD'], host=None)]\n" - ] - } - ], "source": [ "@rt(host='test.foo')\n", "def foo(): return Div(\"Goodbye World\")\n", @@ -2717,38 +2369,27 @@ "\n", "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" - ] + ], + "id": "5ad7c9de1dfce260" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "377012de", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/foo?param=value'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "response = cli.get('/foo')\n", "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ] + ], + "id": "586335adcfb33a2d" }, { - "cell_type": "code", - "execution_count": null, - "id": "2ebd6270", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -2757,26 +2398,26 @@ "\n", "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" - ] + ], + "id": "8597f54fb142ccac" }, { - "cell_type": "code", - "execution_count": null, - "id": "274653c2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def test_r(cli, path, exp, meth='get', hx=False, **kwargs):\n", " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" - ] + ], + "id": "2c5d9e0504d9dc6d" }, { - "cell_type": "code", - "execution_count": null, - "id": "b8a77cde", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@dataclass\n", "class Card: id:str; title:str\n", @@ -2791,14 +2432,14 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ] + ], + "id": "b74164e06e90ba93" }, { - "cell_type": "code", - "execution_count": null, - "id": "ac7b527d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt\n", "class Things:\n", @@ -2806,95 +2447,54 @@ "\n", "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" - ] + ], + "id": "d2041657ae7d1693" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "8a6ed879", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Hi there'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt(\"/hi\")\n", "def get(): return 'Hi there'\n", "\n", "r = cli.get('/hi')\n", "r.text" - ] + ], + "id": "85a19562572eb1f0" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "a8e723fc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Postal'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt(\"/hi\")\n", "def post(): return 'Postal'\n", "\n", "cli.post('/hi').text" - ] + ], + "id": "c4d00de40d55058c" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "274666db", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'testserver'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@app.get(\"/hostie\")\n", "def show_host(req): return req.headers['host']\n", "\n", "cli.get('/hostie').text" - ] + ], + "id": "bb773671b001354a" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "72428702", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Message text was: Hi! with session bar, from client: Address(host='testclient', port=50000)\n" - ] - } - ], "source": [ "@app.get(\"/setsess\")\n", "def set_sess(session):\n", @@ -2910,191 +2510,100 @@ " data = ws.receive_text()\n", "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" - ] + ], + "id": "179cd46bb29d7709" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "36292bf3", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'a yoyo'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt\n", "def yoyo(): return 'a yoyo'\n", "\n", "cli.post('/yoyo').text" - ] + ], + "id": "699ed56f3ffa2946" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "0d813cd0", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "
Text.
\n", - "\n", - "\n" - ] - } - ], "source": [ "@app.get\n", "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" - ] + ], + "id": "2c3172e12bcd3804" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "52dde0da", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - " \n", - "
Text.
\n", - " \n", - "\n", - "\n" - ] - } - ], "source": [ "@app.get\n", "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" - ] + ], + "id": "722386109b88941c" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "d84d98f1", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "
Text.
\n", - "\n", - "\n" - ] - } - ], "source": [ "@app.get\n", "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" - ] + ], + "id": "553fa68944189fb4" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "64343367", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Good day to you, Alexis!'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt('/user/{nm}', name='gday')\n", "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" - ] + ], + "id": "e6f8f50fafafc8eb" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "29a96715", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "
Text.
\n", - "\n", - "\n" - ] - } - ], "source": [ "@app.get\n", "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" - ] + ], + "id": "c7547bb93a3d1054" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "54266599", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'http://testserver/user/Alexis; http://testserver/hostie'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt('/link')\n", "def get(req): return f\"{req.url_for('gday', nm='Alexis')}; {req.url_for('show_host')}\"\n", "\n", "cli.get('/link').text" - ] + ], + "id": "8462a0f064aff4d6" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "162b811e", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Background task completed!\n" - ] - } - ], "source": [ "@app.get(\"/background\")\n", "async def background_task(request):\n", @@ -3104,24 +2613,22 @@ " return P(\"Task started\"), BackgroundTask(long_running_task)\n", "\n", "response = cli.get(\"/background\")" - ] + ], + "id": "3efa3323e4df7c5b" }, { - "cell_type": "code", - "execution_count": null, - "id": "db0281cf", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')" - ] + "execution_count": null, + "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", + "id": "f8e79041441126c8" }, { - "cell_type": "code", - "execution_count": null, - "id": "b827960a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "hxhdr = {'headers':{'hx-request':\"1\"}}\n", "\n", @@ -3144,62 +2651,62 @@ "\n", "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" - ] + ], + "id": "97917c2da8c1b97" }, { - "cell_type": "code", - "execution_count": null, - "id": "1c2311f8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# declaration-time warnings\n", "def _noanno(nope): return nope\n", "def _bare(names:list): return str(names)\n", "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" - ] + ], + "id": "32e87de40093ea3e" }, { - "cell_type": "code", - "execution_count": null, - "id": "381a1b68", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# runtime warnings\n", "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" - ] + ], + "id": "6e0388f8f7a33918" }, { - "cell_type": "code", - "execution_count": null, - "id": "80238019", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" - ] + ], + "id": "5b46014c78131c79" }, { - "cell_type": "code", - "execution_count": null, - "id": "1b4b5717", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" - ] + ], + "id": "6a33532187a12204" }, { - "cell_type": "code", - "execution_count": null, - "id": "dd017867", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt(\"/models/{nm}\")\n", "def get(nm:ModelName): return nm\n", @@ -3218,14 +2725,14 @@ "\n", "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" - ] + ], + "id": "9356fb7d26076190" }, { - "cell_type": "code", - "execution_count": null, - "id": "52d9a3f2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/html/1', headers={'hx-request':\"1\"})\n", "assert '

Next is 2.

' in r.text\n", @@ -3239,25 +2746,14 @@ "assert cli.get('/idxl/?idx=1&idx=g').status_code == 404\n", "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" - ] + ], + "id": "4aecb86c332772fd" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bb8154cc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'got sub/a.b'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "app = FastHTML()\n", "rt = app.route\n", @@ -3267,45 +2763,30 @@ "@app.route(r'/static/{path:path}')\n", "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" - ] + ], + "id": "23ee11e36fe61b4d" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "65a29643", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'also got sub/a.b,1'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cli.get('/static/sub/a.b?a=1').text" - ] + "source": "cli.get('/static/sub/a.b?a=1').text", + "id": "6b7dbbccaee4786d" }, { - "cell_type": "code", - "execution_count": null, - "id": "6bc9564c", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "app.chk = 'foo'" - ] + "execution_count": null, + "source": "app.chk = 'foo'", + "id": "125bbf08e83b1f3f" }, { - "cell_type": "code", - "execution_count": null, - "id": "b8be4ef3", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@app.get(\"/booly/\")\n", "def _(coming:bool=True): return 'Coming' if coming else 'Not coming'\n", @@ -3336,14 +2817,14 @@ "\n", "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" - ] + ], + "id": "638477f27cdfea9f" }, { - "cell_type": "code", - "execution_count": null, - "id": "5f045cf9", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_r(cli, '/booly/?coming=true', 'Coming')\n", "test_r(cli, '/booly/?coming=no', 'Not coming')\n", @@ -3358,59 +2839,59 @@ " ({'HX-Prompt':'Hi%20%C3%A9+%2520', 'HX-Request-Type':'partial'}, 'Hi é+%20'),\n", " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" - ] + ], + "id": "17f7dacb62fbfc5a" }, { - "cell_type": "code", - "execution_count": null, - "id": "52f9f934", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app2', **hxhdr)\n", "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" - ] + ], + "id": "a96bea4a7d78778b" }, { - "cell_type": "code", - "execution_count": null, - "id": "26d316b6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" - ] + ], + "id": "337aae643cfc8ce8" }, { - "cell_type": "code", - "execution_count": null, - "id": "704aeba6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" - ] + ], + "id": "7a9bd99267318977" }, { - "cell_type": "code", - "execution_count": null, - "id": "5fa127d5", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" - ] + ], + "id": "329551bba246c150" }, { - "cell_type": "code", - "execution_count": null, - "id": "356db6c0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt\n", "def meta(): return ((Title('hi'), H1('hi')), (Meta(property='image'), Meta(property='site_name')))\n", @@ -3418,14 +2899,14 @@ "t = cli.post('/meta').text\n", "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" - ] + ], + "id": "fc23736b3a69099e" }, { - "cell_type": "code", - "execution_count": null, - "id": "0aeaac36", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "# Testing POST with Content-Type: application/json\n", "@app.post(\"/bodytext\")\n", @@ -3548,33 +3027,22 @@ "\n", "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" - ] + ], + "id": "4f4651a4a03e6bd7" }, { - "cell_type": "code", - "execution_count": null, - "id": "4034ee37", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]" - ] + "execution_count": null, + "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", + "id": "4868272a396da7a" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "04881594", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "200\n", - "content1,content2\n" - ] - } - ], "source": [ "@rt(\"/uploads\")\n", "async def post(files:list[UploadFile]): return ','.join([(await file.read()).decode() for file in files])\n", @@ -3582,44 +3050,26 @@ "res = cli.post('/uploads', files=files)\n", "print(res.status_code)\n", "print(res.text)" - ] + ], + "id": "45bedd85d15fea3" }, { - "cell_type": "code", - "execution_count": null, - "id": "28a99667", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "res = cli.post('/uploads', files=[files[0]])\n", "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" - ] + ], + "id": "5b1d696b1bb0dfe8" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5bce33f0", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set to 2026-10-30 12:59:28.473222\n" - ] - }, - { - "data": { - "text/plain": [ - "'Session time: 2026-10-30 12:59:28.473222'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "@rt(\"/setsess\")\n", "def get(sess, foo:str=''):\n", @@ -3634,14 +3084,14 @@ "time.sleep(0.01)\n", "\n", "cli.get('/getsess').text" - ] + ], + "id": "89a74a9ed63b0864" }, { - "cell_type": "code", - "execution_count": null, - "id": "249fce31", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt(\"/sess-first\")\n", "def post(sess, name: str):\n", @@ -3654,69 +3104,59 @@ "def get(sess): return sess['name']\n", "\n", "test_eq(cli.get('/getsess-all').text, '2')" - ] + ], + "id": "cdc8f3e6286abb12" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "4d17ab9c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "# Release notes\n" - ] - } - ], "source": [ "@rt(\"/upload\")\n", "async def post(uf:UploadFile): return (await uf.read()).decode()\n", "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" - ] + ], + "id": "872ee1028a67161c" }, { - "cell_type": "code", - "execution_count": null, - "id": "102c17ab", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt(\"/form-submit/{list_id}\")\n", "def options(list_id: str):\n", " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" - ] + ], + "id": "502441c79afad54e" }, { - "cell_type": "code", - "execution_count": null, - "id": "85bf2984", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" - ] + ], + "id": "2f7203ecb23cd645" }, { - "cell_type": "code", - "execution_count": null, - "id": "e4fc13b0", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "from fasthtml.authmw import user_pwd_auth" - ] + "execution_count": null, + "source": "from fasthtml.authmw import user_pwd_auth", + "id": "bbb797a2ab879e64" }, { - "cell_type": "code", - "execution_count": null, - "id": "1f11eab1", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def _not_found(req, exc): return Div('nope')\n", "\n", @@ -3725,14 +3165,14 @@ "txt = cli.get('/').text\n", "assert '
nope
' in txt\n", "assert '' in txt" - ] + ], + "id": "42864ab8da13c1aa" }, { - "cell_type": "code", - "execution_count": null, - "id": "063b7f43", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3741,14 +3181,14 @@ "\n", "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" - ] + ], + "id": "5ef9c12c00a4749" }, { - "cell_type": "code", - "execution_count": null, - "id": "efd6fc6c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3758,14 +3198,14 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ] + ], + "id": "a95117e96c3ff27b" }, { - "cell_type": "code", - "execution_count": null, - "id": "32520197", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3775,32 +3215,28 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ] + ], + "id": "e227a2f8a4c65844" }, { - "cell_type": "markdown", - "id": "967001f3", "metadata": {}, - "source": [ - "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script." - ] + "cell_type": "markdown", + "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", + "id": "6ac4871e67713519" }, { - "cell_type": "code", - "execution_count": null, - "id": "09a5fb96", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "from fasthtml.toaster import *" - ] + "execution_count": null, + "source": "from fasthtml.toaster import *", + "id": "94c99dda004100c5" }, { - "cell_type": "code", - "execution_count": null, - "id": "c7500555", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML(htmx=False, htmx4=True, hdrs=[htmx4src]))\n", "setup_toasts(app)\n", @@ -3822,32 +3258,28 @@ "legacy_rt('/')(index)\n", "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" - ] + ], + "id": "ad5580e80c3c6e36" }, { - "cell_type": "markdown", - "id": "bd0e4c06", "metadata": {}, - "source": [ - "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762)." - ] + "cell_type": "markdown", + "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", + "id": "2a65b631c66dc66a" }, { - "cell_type": "code", - "execution_count": null, - "id": "e2417e5f", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "assert 'id=\"fh-toast-container\"' in r.text" - ] + "execution_count": null, + "source": "assert 'id=\"fh-toast-container\"' in r.text", + "id": "f510adb30a4c7ce4" }, { - "cell_type": "code", - "execution_count": null, - "id": "f95e80d7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML(on_startup=[lambda: setattr(app, '_started', True)],\n", " on_shutdown=[lambda: setattr(app, '_stopped', True)]))\n", @@ -3857,14 +3289,14 @@ "\n", "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" - ] + ], + "id": "b5174061efb43755" }, { - "cell_type": "code", - "execution_count": null, - "id": "190ce9d0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3875,22 +3307,20 @@ "def index(): return app._evt\n", "\n", "with cli: test_eq(cli.get('/').text, 'fired')" - ] + ], + "id": "d7095deb15659610" }, { - "cell_type": "markdown", - "id": "b9fdbdd8", "metadata": {}, - "source": [ - "A route-level `before` can set session data, short-circuit with a response, or raise to block access:" - ] + "cell_type": "markdown", + "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", + "id": "93c4dff4f2ab12b8" }, { - "cell_type": "code", - "execution_count": null, - "id": "f94ea388", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def check_user(sess, user:str=''):\n", " if user == 'admin':\n", @@ -3905,22 +3335,20 @@ "test_eq(cli.get('/dashboard?user=admin').text, 'bar')\n", "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" - ] + ], + "id": "80d1b584926749fe" }, { - "cell_type": "markdown", - "id": "35615416", "metadata": {}, - "source": [ - "Multiple `before` functions run in order, each receiving the injected request params:" - ] + "cell_type": "markdown", + "source": "Multiple `before` functions run in order, each receiving the injected request params:", + "id": "8f6b787dab0a55f9" }, { - "cell_type": "code", - "execution_count": null, - "id": "6b751acb", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def set_a(sess): sess['a'] = 1\n", "def set_b(sess): sess['b'] = 2\n", @@ -3929,22 +3357,20 @@ "def get(sess): return f\"{sess.get('a')},{sess.get('b')}\"\n", "\n", "test_eq(cli.get('/multi').text, '1,2')" - ] + ], + "id": "35912238f99ad24e" }, { - "cell_type": "markdown", - "id": "49cc8791", "metadata": {}, - "source": [ - "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):" - ] + "cell_type": "markdown", + "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", + "id": "281af529700a25a6" }, { - "cell_type": "code", - "execution_count": null, - "id": "83188ab0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "calls = []\n", "def first(sess): \n", @@ -3957,22 +3383,20 @@ "\n", "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" - ] + ], + "id": "696c0126acb6e2" }, { - "cell_type": "markdown", - "id": "a43466d4", "metadata": {}, - "source": [ - "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:" - ] + "cell_type": "markdown", + "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", + "id": "5bd1d46a86fa8615" }, { - "cell_type": "code", - "execution_count": null, - "id": "4457e14e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3981,14 +3405,14 @@ "async def cq(q:str): return f'got {q}'\n", "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" - ] + ], + "id": "8c810cdd71479bf9" }, { - "cell_type": "code", - "execution_count": null, - "id": "59ed14fc", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt('/sleepy')\n", "@cancel_on_disconnect(timeout=0.01)\n", @@ -3997,22 +3421,20 @@ " return 'nope'\n", "\n", "test_fail(lambda: cli.get('/sleepy'))" - ] + ], + "id": "1b0fb60507f67d5e" }, { - "cell_type": "markdown", - "id": "c03768de", "metadata": {}, - "source": [ - "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:" - ] + "cell_type": "markdown", + "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", + "id": "517da992945db2ca" }, { - "cell_type": "code", - "execution_count": null, - "id": "8e78bea6", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "async def _disc(): return {'type': 'http.disconnect'}\n", "cancelled = []\n", @@ -4030,22 +3452,20 @@ "\n", "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" - ] + ], + "id": "cd3d9db054db1cd8" }, { - "cell_type": "markdown", - "id": "6a014add", "metadata": {}, - "source": [ - "## APIRouter" - ] + "cell_type": "markdown", + "source": "## APIRouter", + "id": "cf11141fce1e452b" }, { - "cell_type": "code", - "execution_count": null, - "id": "d5223a9a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class RouteFuncs:\n", @@ -4057,14 +3477,14 @@ " try: return self._funcs[name]\n", " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" - ] + ], + "id": "9327c6c6df6513ac" }, { - "cell_type": "code", - "execution_count": null, - "id": "0f08cb5e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class APIRouter:\n", @@ -4105,24 +3525,22 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" - ] + ], + "id": "b0f23ccd905b1549" }, { - "cell_type": "code", - "execution_count": null, - "id": "22e17ec8", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "ar = APIRouter()" - ] + "execution_count": null, + "source": "ar = APIRouter()", + "id": "f2b3da6a3bdf4171" }, { - "cell_type": "code", - "execution_count": null, - "id": "61030ee4", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@ar(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -4145,25 +3563,25 @@ "\n", "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ] + ], + "id": "7114e29a3aabef4f" }, { - "cell_type": "code", - "execution_count": null, - "id": "cd413b0d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" - ] + ], + "id": "88dca6f4c2eb58c4" }, { - "cell_type": "code", - "execution_count": null, - "id": "8c265ff8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "assert str(yoyo) == '/yoyo'\n", "# ensure route functions are properly discoverable on `APIRouter` and `APIRouter.rt_funcs`\n", @@ -4174,14 +3592,14 @@ "with ExceptionExpected(): ar.rt_funcs.blah()\n", "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" - ] + ], + "id": "cc057dc0f8bee65" }, { - "cell_type": "code", - "execution_count": null, - "id": "8f7c5710", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_eq(cli.get('/hi').text, 'Hi there')\n", "test_eq(cli.post('/hi').text, 'Postal')\n", @@ -4195,37 +3613,35 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ] + ], + "id": "518b2e5210f7fb61" }, { - "cell_type": "code", - "execution_count": null, - "id": "1236de78", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "with cli.websocket_connect('/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ] + ], + "id": "9a02d8a1f576a12e" }, { - "cell_type": "code", - "execution_count": null, - "id": "02a4e649", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "ar2 = APIRouter(\"/products\")" - ] + "execution_count": null, + "source": "ar2 = APIRouter(\"/products\")", + "id": "e8a620009a5610a2" }, { - "cell_type": "code", - "execution_count": null, - "id": "151b9e3c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@ar2(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -4246,25 +3662,25 @@ "\n", "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ] + ], + "id": "faa47549826cdb2a" }, { - "cell_type": "code", - "execution_count": null, - "id": "77ce8548", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" - ] + ], + "id": "8a0a82dd5dfd1479" }, { - "cell_type": "code", - "execution_count": null, - "id": "f1fc8425", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "assert str(yoyo) == '/products/yoyo'\n", "assert ar2.prefix == '/products'\n", @@ -4274,14 +3690,14 @@ "with ExceptionExpected(): ar2.blah()\n", "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" - ] + ], + "id": "2ec82fec8f829227" }, { - "cell_type": "code", - "execution_count": null, - "id": "f265860d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "test_eq(cli.get('/products/hi').text, 'Hi there')\n", "test_eq(cli.post('/products/hi').text, 'Postal')\n", @@ -4293,38 +3709,38 @@ "\n", "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" - ] + ], + "id": "b2d8b49bf2def958" }, { - "cell_type": "code", - "execution_count": null, - "id": "e38d99cf", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "with cli.websocket_connect('/products/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ] + ], + "id": "2be2cf9204edd712" }, { - "cell_type": "code", - "execution_count": null, - "id": "259a0f53", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" - ] + ], + "id": "86ce3e2ac089558d" }, { - "cell_type": "code", - "execution_count": null, - "id": "f61d110c", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@ar.get\n", "def hi2(): return 'Hi there'\n", @@ -4339,32 +3755,28 @@ "def _(): return 'Hi there'\n", "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" - ] + ], + "id": "d9484106682b7103" }, { - "cell_type": "markdown", - "id": "dfa6f859", "metadata": {}, - "source": [ - "## Extras" - ] + "cell_type": "markdown", + "source": "## Extras", + "id": "5bf36467a1cf679" }, { - "cell_type": "code", - "execution_count": null, - "id": "7438435e", "metadata": {}, + "cell_type": "code", "outputs": [], - "source": [ - "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" - ] + "execution_count": null, + "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", + "id": "3a25293096797025" }, { - "cell_type": "code", - "execution_count": null, - "id": "e518de83", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def cookie(key: str, value=\"\", max_age=None, expires=None, path=\"/\", domain=None, secure=False, httponly=False, samesite=\"lax\",):\n", @@ -4382,42 +3794,14 @@ " cookie[key][\"samesite\"] = samesite\n", " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "919e600a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - " \n", - " FastHTML page\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - "\n" - ] - }, - { - "data": { - "text/plain": [ - "'Cookie was set at time 12:59:28.565407'" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } ], + "id": "a95005810c174483" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "@rt(\"/setcookie\")\n", "def get(req): return cookie('now', datetime.now())\n", @@ -4428,28 +3812,28 @@ "print(cli.get('/setcookie').text)\n", "time.sleep(0.01)\n", "cli.get('/getcookie').text" - ] + ], + "id": "1cc5f8ac88e08c3a" }, { - "cell_type": "code", - "execution_count": null, - "id": "8816f277", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def reg_re_param(m, s):\n", " \"Register a Starlette URL convertor named `m` matching regex `s`\"\n", " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" - ] + ], + "id": "6fe69203d6bfc884" }, { - "cell_type": "code", - "execution_count": null, - "id": "e9535978", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "# Starlette doesn't have the '?', so it chomps the whole remaining URL\n", @@ -4462,14 +3846,14 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`\"\n", " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" - ] + ], + "id": "577087d16b0a8d19" }, { - "cell_type": "code", - "execution_count": null, - "id": "9a70842e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "reg_re_param(\"imgext\", \"ico|gif|jpg|jpeg|webm|pdf\")\n", "\n", @@ -4477,25 +3861,25 @@ "def get(fn:str, path:str, ext:str): return f\"Getting {fn}.{ext} from /{path}\"\n", "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" - ] + ], + "id": "cfb7108b7420e734" }, { - "cell_type": "code", - "execution_count": null, - "id": "8bb51383", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" - ] + ], + "id": "1edc14249656a74" }, { - "cell_type": "code", - "execution_count": null, - "id": "b31de65a", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -4503,25 +3887,25 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')\"\n", " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" - ] + ], + "id": "24d8e56439dadaff" }, { - "cell_type": "code", - "execution_count": null, - "id": "d4ff5919", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" - ] + ], + "id": "eb0c931c74eaa1c2" }, { - "cell_type": "code", - "execution_count": null, - "id": "f63b7a03", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class StaticNoCache(StaticFiles):\n", @@ -4530,14 +3914,14 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" - ] + ], + "id": "f4932f373f09a958" }, { - "cell_type": "code", - "execution_count": null, - "id": "74801f94", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class StaticImmutable(StaticFiles):\n", @@ -4546,22 +3930,20 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" - ] + ], + "id": "52c7a246c8adc736" }, { - "cell_type": "markdown", - "id": "1c0531b8", "metadata": {}, - "source": [ - "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide." - ] + "cell_type": "markdown", + "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", + "id": "995f8b4492749588" }, { - "cell_type": "code", - "execution_count": null, - "id": "27910e71", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "statd = Path(tempfile.mkdtemp())\n", "(statd/'app.css').write_text('body{}')\n", @@ -4569,14 +3951,14 @@ "stat_cli = TestClient(stat_app)\n", "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" - ] + ], + "id": "32806b81e81b7a90" }, { - "cell_type": "code", - "execution_count": null, - "id": "e38fd6c7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@flexicache(mtime_policy(arg=0))\n", @@ -4588,22 +3970,20 @@ " p = Path(root)/u.path.lstrip('/')\n", " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" - ] + ], + "id": "a718591914c37267" }, { - "cell_type": "markdown", - "id": "bc5b7561", "metadata": {}, - "source": [ - "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload." - ] + "cell_type": "markdown", + "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", + "id": "320f98ff87461a78" }, { - "cell_type": "code", - "execution_count": null, - "id": "28e4a133", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "tmp = Path(tempfile.mkdtemp())\n", "(tmp/'a.css').write_text('body{}')\n", @@ -4616,26 +3996,26 @@ "(tmp/'a.css').write_text('body{color:red}')\n", "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" - ] + ], + "id": "3989aaa274753efa" }, { - "cell_type": "code", - "execution_count": null, - "id": "7189daf8", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "from functools import wraps\n", "from inspect import signature, isawaitable" - ] + ], + "id": "445f9099604c2004" }, { - "cell_type": "code", - "execution_count": null, - "id": "7eed23b7", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty):\n", @@ -4647,14 +4027,14 @@ " f.__signature__ = sig.replace(parameters=new_params)\n", " f.__annotations__[name] = typ\n", " return f" - ] + ], + "id": "2aa1839a42ee118e" }, { - "cell_type": "code", - "execution_count": null, - "id": "9e5a9e88", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class into:\n", @@ -4677,30 +4057,26 @@ " anno = p.annotation if p.annotation is not Parameter.empty else NoneType\n", " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" - ] + ], + "id": "89ae3e66b54e884b" }, { - "cell_type": "markdown", - "id": "eba9a334", "metadata": {}, - "source": [ - "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically." - ] + "cell_type": "markdown", + "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", + "id": "d1cd9dce28dea2f4" }, { - "cell_type": "markdown", - "id": "163acb67", "metadata": {}, - "source": [ - "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params." - ] + "cell_type": "markdown", + "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", + "id": "56646c83620cb9d7" }, { - "cell_type": "code", - "execution_count": null, - "id": "50a9abbd", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -4709,54 +4085,50 @@ "@rt\n", "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" - ] + ], + "id": "aec9ef14145e0a6c" }, { - "cell_type": "markdown", - "id": "9aa0ac77", "metadata": {}, - "source": [ - "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\"." - ] + "cell_type": "markdown", + "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", + "id": "c3af92a37364ea5" }, { - "cell_type": "code", - "execution_count": null, - "id": "3a08f786", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/hello')\n", "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" - ] + ], + "id": "2fba1dc094c12f8d" }, { - "cell_type": "markdown", - "id": "5a172800", "metadata": {}, - "source": [ - "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`." - ] + "cell_type": "markdown", + "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", + "id": "e9335b5696d455dd" }, { - "cell_type": "code", - "execution_count": null, - "id": "0fcfbf16", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "r = cli.get('/hello?name=Jeremy&title=Greetings')\n", "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" - ] + ], + "id": "be95c9878d5c00e8" }, { - "cell_type": "code", - "execution_count": null, - "id": "1960d7ff", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class MiddlewareBase:\n", @@ -4765,14 +4137,14 @@ " if scope[\"type\"] in (\"http\", \"websocket\"): return HTTPConnection(scope)\n", " await self._app(scope, receive, send)\n", " " - ] + ], + "id": "f514a8004cafc6fb" }, { - "cell_type": "code", - "execution_count": null, - "id": "83a20f93", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "class FtResponse:\n", @@ -4788,14 +4160,14 @@ " if not tasks: tasks = self.background\n", " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" - ] + ], + "id": "9cd4ca4bc9101e57" }, { - "cell_type": "code", - "execution_count": null, - "id": "a9e66b7d", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "@rt('/ftr')\n", "def get():\n", @@ -4808,31 +4180,20 @@ "test_eq(r.headers['location'], '/foo/1')\n", "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" - ] + ], + "id": "d297d2a8e58b679b" }, { - "cell_type": "markdown", - "id": "94705ca7", "metadata": {}, - "source": [ - "Test on a single background task:" - ] + "cell_type": "markdown", + "source": "Test on a single background task:", + "id": "63be92ace16f29b" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "8cf7433e", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Starting slow task\n", - "Finished slow task\n" - ] - } - ], "source": [ "def my_slow_task():\n", " print('Starting slow task') \n", @@ -4846,49 +4207,34 @@ "\n", "test_eq(r.status_code, 200)\n", "\n" - ] + ], + "id": "5c959d6249ee4516" }, { - "cell_type": "markdown", - "id": "47c26feb", "metadata": {}, - "source": [ - "Test multiple background tasks:" - ] + "cell_type": "markdown", + "source": "Test multiple background tasks:", + "id": "5232230a1620cb96" }, { - "cell_type": "code", - "execution_count": null, - "id": "76d25483", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "def increment(amount):\n", " amount = amount/1000\n", " print(f'Sleeping for {amount}s') \n", " time.sleep(amount)\n", " print(f'Slept for {amount}s') " - ] + ], + "id": "8e539b41580bba31" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "bd7cf045", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.0s\n", - "Slept for 0.0s\n", - "Sleeping for 0.001s\n", - "Slept for 0.001s\n", - "Sleeping for 0.002s\n", - "Slept for 0.002s\n" - ] - } - ], "source": [ "@rt\n", "def backgrounds():\n", @@ -4898,27 +4244,14 @@ "\n", "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" - ] + ], + "id": "fe31b15b3bb1378d" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], "execution_count": null, - "id": "5310fea9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.0s\n", - "Slept for 0.0s\n", - "Sleeping for 0.001s\n", - "Slept for 0.001s\n", - "Sleeping for 0.002s\n", - "Slept for 0.002s\n" - ] - } - ], "source": [ "@rt\n", "def backgrounds2():\n", @@ -4927,37 +4260,14 @@ "\n", "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "348f9bbd", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sleeping for 0.0s\n", - "Slept for 0.0s\n", - "Sleeping for 0.001s\n", - "Slept for 0.001s\n", - "Sleeping for 0.002s\n", - "Slept for 0.002s\n" - ] - }, - { - "data": { - "text/plain": [ - "{'status': 'done'}" - ] - }, - "execution_count": null, - "metadata": {}, - "output_type": "execute_result" - } ], + "id": "2a8ce6fa46c64aef" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "@rt\n", "def backgrounds3():\n", @@ -4967,14 +4277,14 @@ "r = cli.get('/backgrounds3')\n", "test_eq(r.status_code, 200)\n", "r.json()" - ] + ], + "id": "89533d87849a1d1c" }, { - "cell_type": "code", - "execution_count": null, - "id": "9dc1025e", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def unqid(seeded=False):\n", @@ -4982,28 +4292,28 @@ " id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4()\n", " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" - ] + ], + "id": "77b7a333bc7aae11" }, { - "cell_type": "code", - "execution_count": null, - "id": "5b67e014", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "def _add_ids(s):\n", " if not isinstance(s, FT): return\n", " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" - ] + ], + "id": "8e69a404c8e6513a" }, { - "cell_type": "code", - "execution_count": null, - "id": "1f590f25", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5016,22 +4326,20 @@ " for o in conns.values(): await o(s)\n", " app._send = send\n", " return send" - ] + ], + "id": "8a6671f803a4aa34" }, { - "cell_type": "markdown", - "id": "98cdeef1", "metadata": {}, - "source": [ - "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:" - ] + "cell_type": "markdown", + "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", + "id": "55954a5c03d669e4" }, { - "cell_type": "code", - "execution_count": null, - "id": "7c0ab080", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "push_app = FastHTML()\n", "async def relay(msg:str): await push(Div(msg, id='status'))\n", @@ -5041,14 +4349,14 @@ " with client.websocket_connect('/ws') as ws:\n", " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" - ] + ], + "id": "4c6e6a245ddc0e21" }, { - "cell_type": "code", - "execution_count": null, - "id": "a8a91edd", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "devtools_loc = \"/.well-known/appspecific/com.chrome.devtools.json\"\n", @@ -5059,14 +4367,14 @@ " if not uuid: uuid = str(uuid5(NAMESPACE_URL, str(path)))\n", " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" - ] + ], + "id": "e73f8e2cf44bc34c" }, { - "cell_type": "code", - "execution_count": null, - "id": "e27908c0", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5079,14 +4387,14 @@ " client = httpx2.AsyncClient() if asink else httpx2.Client()\n", " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" - ] + ], + "id": "f8bea79247120ca0" }, { - "cell_type": "code", - "execution_count": null, - "id": "c845f437", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5095,14 +4403,14 @@ " if not cookie or cookie == 'null': return {}\n", " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" - ] + ], + "id": "c21145b1f04a7374" }, { - "cell_type": "code", - "execution_count": null, - "id": "49260e9b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| export\n", "@patch\n", @@ -5118,38 +4426,37 @@ " return self.app.decode_session(cookie)\n", "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" - ] + ], + "id": "1b46d16bfa5c8fc3" }, { - "cell_type": "code", - "execution_count": null, - "id": "6830083b", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" - ] + ], + "id": "1e19d16f00e6ef72" }, { - "cell_type": "markdown", - "id": "474e14b4", "metadata": {}, - "source": [ - "## Export -" - ] + "cell_type": "markdown", + "source": "## Export -", + "id": "8108539e4e0df8ab" }, { - "cell_type": "code", - "execution_count": null, - "id": "d211e8e2", "metadata": {}, + "cell_type": "code", "outputs": [], + "execution_count": null, "source": [ "#| hide\n", "import nbdev\n", "nbdev.nbdev_export()" - ] + ], + "id": "f13f115109a75cc5" } ], "metadata": { From 8a97c6a43592be9ab7b3231f1ce6fa4d6d2e12aa Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:18:20 -0300 Subject: [PATCH 12/22] test: add tests for request parameter alias handling --- tests/test_alias.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_alias.py diff --git a/tests/test_alias.py b/tests/test_alias.py new file mode 100644 index 00000000..059fe82b --- /dev/null +++ b/tests/test_alias.py @@ -0,0 +1,42 @@ +from fasthtml.common import * +from starlette.testclient import TestClient +from typing import Annotated + +app, rt = fast_app() +cli = TestClient(app) + +@rt("/query") +def get(user_id: Annotated[str, Alias("user-id")]): return f"q:{user_id}" + +@rt("/form", methods=["post"]) +def post(user_id: Annotated[str, Alias("user-id")]): return f"f:{user_id}" + +@rt("/path/{item_id}") +def get(item_id: Annotated[int, Alias("item_id")]): return f"p:{item_id}" + +@rt("/header") +def get(x_api_key: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key}" + +@rt("/cookie") +def get(req, session_id: Annotated[str, Alias("session-id")]): return f"c:{session_id}" + +@rt("/int") +def get(count: Annotated[int, Alias("the-count")]): return f"i:{count + 1}" + +@rt("/plain") +def get(user_id: str): return f"plain:{user_id}" + +@rt("/missing") +def get(user_id: Annotated[str, Alias("user-id")]): return user_id + +def test_query_alias(): assert cli.get('/query?user-id=abc').text == 'q:abc' +def test_form_alias(): assert cli.post('/form', data={'user-id': 'abc'}).text == 'f:abc' +def test_path_alias(): assert cli.get('/path/7').text == 'p:7' +def test_header_alias(): assert cli.get('/header', headers={'X-Api-Key': 'k1'}).text == 'h:k1' +def test_cookie_alias(): assert cli.get('/cookie', cookies={'session-id': 's1'}).text == 'c:s1' +def test_int_alias(): assert cli.get('/int?the-count=4').text == 'i:5' +def test_plain_unchanged(): assert cli.get('/plain?user_id=z').text == 'plain:z' +def test_snake_name_not_used_for_alias(): assert cli.get('/query?user_id=abc').status_code == 400 +def test_missing_message_uses_alias(): + r = cli.get('/missing') + assert r.status_code == 400 and 'Missing required field: user-id' in r.text From 1d45ed8537d897df3e948e0b5027dea0fe3cd5ed Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:43:08 -0300 Subject: [PATCH 13/22] refactor: update code cell metadata in 00_core.ipynb --- nbs/api/00_core.ipynb | 578 +++++++++++++++++++++--------------------- 1 file changed, 289 insertions(+), 289 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index ea38be7c..508b38c7 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "3f786c1dd3e6d8db" + "id": "54673de215cc02ae" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "d4f4d2a5ada81079" + "id": "5916dab6f96512a1" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "a20bc9c5cf33ca9d" + "id": "72dfc5f47fc2b065" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "f25b8cebe2ad04da" + "id": "dd3e843071e92d8e" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "13be9a37727633e1" + "id": "c67bdaa8a66843c6" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "c5ce68a315f5c605" + "id": "726d7eb2e547e91f" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "f0f6390fdbedc734" + "id": "aa24a7d24ef3a67f" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "cc627854637f64be" + "id": "d01470dc1f26eaab" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "bbe438ea713de1a8" + "id": "154a3beb3539ad6e" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "18295066a96407ae" + "id": "8e3db5510b7b730f" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "aad5601af0e355af" + "id": "4c46c118110f5be1" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "4b914cd2d2f32f6c" + "id": "715e95820af9c1c7" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "c15e1d6a7e2b7e4a" + "id": "b264cb3b214b2ed1" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "b7f6b0483a096d32" + "id": "8e2c0d6026aca500" }, { "metadata": {}, @@ -240,7 +240,7 @@ " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "4010a03d4a4cff88" + "id": "571a0464934df585" }, { "metadata": {}, @@ -255,7 +255,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "f89046ee480e36b1" + "id": "f54f730559c76812" }, { "metadata": {}, @@ -266,7 +266,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "dd1139d65bb86dfb" + "id": "9005fbb397b9854d" }, { "metadata": {}, @@ -277,13 +277,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "db6dfa62177494e" + "id": "100a7905b7d1eac6" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "565092da5fd23f0" + "id": "c6f771ba2e739899" }, { "metadata": {}, @@ -294,7 +294,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "65f97cf4a57869b1" + "id": "42a99dc9ab82725f" }, { "metadata": {}, @@ -310,7 +310,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "ff28371b0eaa94c" + "id": "2180560267867106" }, { "metadata": {}, @@ -331,7 +331,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "116cdc2ba4c1da1d" + "id": "85f88140a3cdcee4" }, { "metadata": {}, @@ -352,7 +352,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "398a7dad7f5f67d4" + "id": "33e76e53be7069c6" }, { "metadata": {}, @@ -370,7 +370,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "aa6c1ec4aa689b59" + "id": "40fbbab8eaaf6658" }, { "metadata": {}, @@ -383,7 +383,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "a077ee4e079d745a" + "id": "6fe7403e858b83c8" }, { "metadata": {}, @@ -397,7 +397,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "f597b0ffe2803a1e" + "id": "1c1ab34e8e7246be" }, { "metadata": {}, @@ -411,7 +411,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "647812de36af6567" + "id": "9e7b36dc9778ee69" }, { "metadata": {}, @@ -419,7 +419,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "65f8b52017efe8b1" + "id": "69afb3e3172a9254" }, { "metadata": {}, @@ -434,7 +434,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "f5c7bc122bcf3a4e" + "id": "c06896c5144a3804" }, { "metadata": {}, @@ -442,7 +442,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "aa2b13597da91281" + "id": "c9b80df0c889c12f" }, { "metadata": {}, @@ -456,7 +456,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "c48b1894c429b354" + "id": "6eb074a576040794" }, { "metadata": {}, @@ -468,7 +468,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "17a8db0eaed1e51c" + "id": "ded2885cfd3dba08" }, { "metadata": {}, @@ -479,7 +479,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "44d1591fde10dece" + "id": "d336e12f9cc9550e" }, { "metadata": {}, @@ -490,7 +490,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "98af78b583ae6ab" + "id": "11848044a63834a4" }, { "metadata": {}, @@ -506,7 +506,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "8a415bf0ea6f622f" + "id": "974f5b2ed3960fd5" }, { "metadata": {}, @@ -520,7 +520,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "ee1ad0926149acdf" + "id": "2e4544782ea49be7" }, { "metadata": {}, @@ -534,7 +534,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "6d67eb875fd651aa" + "id": "dc31fbcbad56bec8" }, { "metadata": {}, @@ -545,7 +545,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "2b75aef99f3233e" + "id": "4848131291f46d6c" }, { "metadata": {}, @@ -572,13 +572,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "6303b3d2a7ffa4ad" + "id": "2faa239b806247c4" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "b72a46be2e6cc9ef" + "id": "2f75067ec21c2ae" }, { "metadata": {}, @@ -592,7 +592,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "7040a70ddc39d9f6" + "id": "6631ac703e8a731e" }, { "metadata": {}, @@ -606,7 +606,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "916fc4b111857f80" + "id": "69139443ba149035" }, { "metadata": {}, @@ -630,7 +630,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "b6777d96f0291d58" + "id": "7f32f491183f452b" }, { "metadata": {}, @@ -647,13 +647,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "f85d3ee49247287c" + "id": "c8206d6bd0604a28" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "4be9f09a003c0045" + "id": "46979364e27f3113" }, { "metadata": {}, @@ -669,7 +669,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "a1b7f0b5c6ce8ac0" + "id": "c5c96f3893f0e118" }, { "metadata": {}, @@ -690,7 +690,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "b41d50ec84d3abd5" + "id": "919bb9ccf0676e86" }, { "metadata": {}, @@ -707,13 +707,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "97eeb77679bd2f8b" + "id": "398e38665eff97a8" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "56936fa2386c2bcc" + "id": "e64b2d109943f0a1" }, { "metadata": {}, @@ -726,13 +726,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "ba168fb917abf529" + "id": "d2e3b81e73e289cd" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "48724c70b61d9fbf" + "id": "aaca49fa25d143a7" }, { "metadata": {}, @@ -746,7 +746,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "5ec7b2f219030178" + "id": "5b64479106200ee4" }, { "metadata": {}, @@ -758,7 +758,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "6f68bc0070827210" + "id": "2afa078586f00b5e" }, { "metadata": {}, @@ -826,7 +826,7 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "8aa1c500837660bd" + "id": "71a4c96e98f1a07e" }, { "metadata": {}, @@ -839,7 +839,7 @@ " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "e4b1d85a2a5d2885" + "id": "bbff13b8e8648a68" }, { "metadata": {}, @@ -850,7 +850,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "f34126d1cde29f61" + "id": "80256940a8e5f387" }, { "metadata": {}, @@ -863,7 +863,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "a2c22342a507911" + "id": "7fe7f9e6dd9e0fd4" }, { "metadata": {}, @@ -883,7 +883,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "151e1fdb275740e5" + "id": "7c1bf4fd9f4eef6a" }, { "metadata": {}, @@ -901,7 +901,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "6ae32895f4eb371d" + "id": "1a846dd3bac66088" }, { "metadata": {}, @@ -919,7 +919,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "4507d0395e7f6121" + "id": "4608aeb4fa1d7dcb" }, { "metadata": {}, @@ -941,19 +941,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "a074d981ec0d03d" + "id": "96e204c9cdc7b7d4" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "db378609f8ab795b" + "id": "b96b04e856f4b4df" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "200212e4df82f398" + "id": "6cf70210a9865783" }, { "metadata": {}, @@ -971,13 +971,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "1d2602318cb04a61" + "id": "7b9108615e5d341d" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "e2aa1e7e7629dd48" + "id": "e75abc668fd0a60f" }, { "metadata": {}, @@ -995,7 +995,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "29e40d3211ed08bd" + "id": "3bd58f90d130f5ae" }, { "metadata": {}, @@ -1013,7 +1013,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "2c3aa048ffeb42c8" + "id": "7f07a85cea1bfda7" }, { "metadata": {}, @@ -1025,7 +1025,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "97f3c80aa48ff101" + "id": "d03f02e4056a5f46" }, { "metadata": {}, @@ -1039,7 +1039,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "8e9006527c670be6" + "id": "333d104176d958e8" }, { "metadata": {}, @@ -1051,7 +1051,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "62836c8032499267" + "id": "c28d4b7161a95fca" }, { "metadata": {}, @@ -1062,13 +1062,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "93d5b5461c789cc0" + "id": "3243918e0476c605" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "4560ed04c182ea9f" + "id": "ea9d54f85dde85c5" }, { "metadata": {}, @@ -1082,7 +1082,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "1f84e03104d49a98" + "id": "65d3fb37a299d758" }, { "metadata": {}, @@ -1118,7 +1118,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "71be83675c9f169" + "id": "ecd29a00bb3a3c14" }, { "metadata": {}, @@ -1134,7 +1134,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "5e04620f390af7d7" + "id": "6374e98d7c93d8b1" }, { "metadata": {}, @@ -1149,7 +1149,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "680c20728829fbf5" + "id": "b79cd55107a4ab92" }, { "metadata": {}, @@ -1162,7 +1162,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "495ade814b13e067" + "id": "c5cad1b69635cc05" }, { "metadata": {}, @@ -1182,13 +1182,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "27bdb0952b3e1455" + "id": "75b3a0aa7a47a1b7" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "6f0a1d02da42b1ee" + "id": "f07dedce5d9bcf38" }, { "metadata": {}, @@ -1201,7 +1201,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "c52ce0229dbce13" + "id": "13d6c0097988ab92" }, { "metadata": {}, @@ -1215,7 +1215,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "8b8fec82b5e03a38" + "id": "3709e60a7cc8184e" }, { "metadata": {}, @@ -1226,7 +1226,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "497dcb292b6854f8" + "id": "77981132c364e4cc" }, { "metadata": {}, @@ -1244,7 +1244,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "79e4255c6e39b9e3" + "id": "f774a63aabdadefd" }, { "metadata": {}, @@ -1258,7 +1258,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "ac7b17844f431e8d" + "id": "6aae8831fe46c17a" }, { "metadata": {}, @@ -1292,7 +1292,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "70267e21152a6055" + "id": "8d8cd62e6ee3f425" }, { "metadata": {}, @@ -1303,7 +1303,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "c6c76c290faa38b9" + "id": "dd4ac3049bb2acb0" }, { "metadata": {}, @@ -1322,7 +1322,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "93fa1064564563cb" + "id": "e88e3588b714065a" }, { "metadata": {}, @@ -1335,7 +1335,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "e5b7576e77e33db9" + "id": "58be48f27d142ebd" }, { "metadata": {}, @@ -1352,7 +1352,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "61fb5baad79b5193" + "id": "6431603002304136" }, { "metadata": {}, @@ -1361,7 +1361,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "25c200a3ee73995a" + "id": "552cf8818d74e78f" }, { "metadata": {}, @@ -1375,7 +1375,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "eaee9ec56b374b67" + "id": "232d36b4dd20ed08" }, { "metadata": {}, @@ -1400,7 +1400,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "35bfa13b8e450d11" + "id": "5eef360551d25c43" }, { "metadata": {}, @@ -1424,7 +1424,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "b082dc01906b3181" + "id": "2c118553dfa340c1" }, { "metadata": {}, @@ -1437,7 +1437,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "996138cef8668ae" + "id": "1deab5e82c1adc72" }, { "metadata": {}, @@ -1465,7 +1465,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "161df71b1b684993" + "id": "7632e8aeb7ed3c51" }, { "metadata": {}, @@ -1481,7 +1481,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "eb205cf1b819ddae" + "id": "1f84dbac3a368799" }, { "metadata": {}, @@ -1495,7 +1495,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "8bab4012aef733af" + "id": "f9f093d21633f122" }, { "metadata": {}, @@ -1523,13 +1523,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "fe59c71ad25fba98" + "id": "81fbfb6af3ef7069" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "a87a87c115d7ab5f" + "id": "f743a1d64e20d8b4" }, { "metadata": {}, @@ -1537,7 +1537,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "4f10ad35d4a74eeb" + "id": "165c3137837907b6" }, { "metadata": {}, @@ -1554,7 +1554,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "f9e3a1987936a6c5" + "id": "9a3946272ac28f58" }, { "metadata": {}, @@ -1572,7 +1572,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "f5d7dc946b3f66b1" + "id": "bf8f889c5daafb34" }, { "metadata": {}, @@ -1585,7 +1585,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "f0a9ff0725182815" + "id": "aff6b95d0192c21d" }, { "metadata": {}, @@ -1603,7 +1603,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "714803a8ed1ed267" + "id": "5bcd45457b91f54b" }, { "metadata": {}, @@ -1623,13 +1623,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "94d54ea53eb972f7" + "id": "558d58d3c3542441" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "f7f245937128dba8" + "id": "1e5101c35e818308" }, { "metadata": {}, @@ -1637,7 +1637,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "7a51473516c833ec" + "id": "a3e4cbc388fa9c1b" }, { "metadata": {}, @@ -1648,13 +1648,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "2e810fff2275acfe" + "id": "2c9a4f6013d24b55" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "f29f68f415c1e88e" + "id": "cbdf99bf51fb04ef" }, { "metadata": {}, @@ -1666,7 +1666,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "16c478cfb8b7748e" + "id": "15cb338ec7bd902f" }, { "metadata": {}, @@ -1687,7 +1687,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "492e4e3dead5768d" + "id": "885036e117ea4b5a" }, { "metadata": {}, @@ -1720,7 +1720,7 @@ " };\n", "\"\"\"))\n" ], - "id": "8f79f2cd9880ad30" + "id": "c29093b6a137a596" }, { "metadata": {}, @@ -1755,7 +1755,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "6096097c1db566fa" + "id": "8620dead102b860a" }, { "metadata": {}, @@ -1802,13 +1802,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "ee5d4b661700ce01" + "id": "1e1edb3b5fb58c5" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "50b7059e3eedea1d" + "id": "df09f97471ee33bd" }, { "metadata": {}, @@ -1834,7 +1834,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "d0f727c1b16d6440" + "id": "18a853f06439ae5b" }, { "metadata": {}, @@ -1854,7 +1854,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "ec5f4a1ef1023f84" + "id": "d0da83816779ea5d" }, { "metadata": {}, @@ -1870,7 +1870,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "bebee3681051f082" + "id": "2d0f3ce87f487b7" }, { "metadata": {}, @@ -1889,7 +1889,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "53af25b6cce9bc6b" + "id": "cb5ee740fd53ee17" }, { "metadata": {}, @@ -1900,7 +1900,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "17c7250429fd19f1" + "id": "5802575f24b7e70b" }, { "metadata": {}, @@ -1937,7 +1937,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "6626baf208595512" + "id": "d866ea4ab96589d9" }, { "metadata": {}, @@ -1955,7 +1955,7 @@ " self.add_route(route)\n", " return func" ], - "id": "83e446817d470f70" + "id": "c470c908745b7cf3" }, { "metadata": {}, @@ -1970,7 +1970,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "6096911dd3f83426" + "id": "2146b4be1a97821d" }, { "metadata": {}, @@ -1984,7 +1984,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "36d6fa0a562a2c16" + "id": "df18d9fa22f571f1" }, { "metadata": {}, @@ -2006,7 +2006,7 @@ "\n", " return _lf()" ], - "id": "dd60709ea16e90e" + "id": "db956d8ef2284772" }, { "metadata": {}, @@ -2019,7 +2019,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "36ec67ca52fe888e" + "id": "f9ca5ed307e59042" }, { "metadata": {}, @@ -2031,7 +2031,7 @@ " def g(): ...\n", " return g" ], - "id": "de50eeca986fdb82" + "id": "3049386a7b3d0bd3" }, { "metadata": {}, @@ -2042,7 +2042,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "14afe8a1f2663eb8" + "id": "84f01125b5865bf5" }, { "metadata": {}, @@ -2062,7 +2062,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "7bdf8a274dd92421" + "id": "deb6de6d7af52f36" }, { "metadata": {}, @@ -2095,7 +2095,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "1a22c77950f4ee73" + "id": "901a4cdc6bf2cc7a" }, { "metadata": {}, @@ -2113,7 +2113,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "8ba498d71421b1c0" + "id": "cf1e52f55047b225" }, { "metadata": {}, @@ -2127,7 +2127,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "dcdc879f26db4e6" + "id": "f1156a4b59658096" }, { "metadata": {}, @@ -2140,13 +2140,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "1ff1a26dded6c3b8" + "id": "c41eaf5a89ed6453" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "c2edb43ac38ef019" + "id": "d38c086bbb205515" }, { "metadata": {}, @@ -2161,7 +2161,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "c4ebeca9aef8ff55" + "id": "20cbcdcaa6db1234" }, { "metadata": {}, @@ -2195,13 +2195,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "d0a02093f42266bf" + "id": "f08636dac1efdae7" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "e8cab1a72e4e2cd3" + "id": "1603490fee1cd916" }, { "metadata": {}, @@ -2216,13 +2216,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "d758bc40a92109f" + "id": "13ed96bb4c14ca11" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "25f1df75b73c0566" + "id": "3f926352a5fb9f35" }, { "metadata": {}, @@ -2245,13 +2245,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "a0e7d14054b42734" + "id": "3f538226afa6ae27" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "6fa28675a503052b" + "id": "653b6ecf5a4b8633" }, { "metadata": {}, @@ -2273,7 +2273,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "64c2a5739dd998e5" + "id": "fcd6b50abcdf8e93" }, { "metadata": {}, @@ -2294,7 +2294,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "cb229495a0755196" + "id": "603310edfe21d02e" }, { "metadata": {}, @@ -2307,19 +2307,19 @@ "\n", "cli.get('/').text" ], - "id": "1ce9426cf6938597" + "id": "d30975982ffa1d87" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "fea360b8c6c5135c" + "id": "8c6958ad8ea4de61" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "d140cd4c811c8f7f" + "id": "f29127574a568d19" }, { "metadata": {}, @@ -2327,7 +2327,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "f507ae78ecd5da64" + "id": "3a4529333e1c3c34" }, { "metadata": {}, @@ -2335,7 +2335,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "6fc2ddec3151fd52" + "id": "c7fc061f1affd76e" }, { "metadata": {}, @@ -2354,7 +2354,7 @@ "\n", "foo.to(param='value')" ], - "id": "65ba7460a0553aa6" + "id": "27979f751d1fd36e" }, { "metadata": {}, @@ -2370,7 +2370,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "5ad7c9de1dfce260" + "id": "a4732af26da7fd8" }, { "metadata": {}, @@ -2383,7 +2383,7 @@ "\n", "foo.to(param='value')" ], - "id": "586335adcfb33a2d" + "id": "bf0e0d7ebae1009c" }, { "metadata": {}, @@ -2399,7 +2399,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "8597f54fb142ccac" + "id": "2218fb53eff7b52f" }, { "metadata": {}, @@ -2411,7 +2411,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "2c5d9e0504d9dc6d" + "id": "d48456ce6cddb90d" }, { "metadata": {}, @@ -2433,7 +2433,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "b74164e06e90ba93" + "id": "e897e7712a32d21d" }, { "metadata": {}, @@ -2448,7 +2448,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "d2041657ae7d1693" + "id": "b184bb689cd8a0ee" }, { "metadata": {}, @@ -2462,7 +2462,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "85a19562572eb1f0" + "id": "9758b81739fa81a" }, { "metadata": {}, @@ -2475,7 +2475,7 @@ "\n", "cli.post('/hi').text" ], - "id": "c4d00de40d55058c" + "id": "67fe24f6f0bcfec" }, { "metadata": {}, @@ -2488,7 +2488,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "bb773671b001354a" + "id": "4164e132e048c91" }, { "metadata": {}, @@ -2511,7 +2511,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "179cd46bb29d7709" + "id": "c15b8e7b7638573c" }, { "metadata": {}, @@ -2524,7 +2524,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "699ed56f3ffa2946" + "id": "8239a9ed9b968de8" }, { "metadata": {}, @@ -2536,7 +2536,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "2c3172e12bcd3804" + "id": "fac64641823ab881" }, { "metadata": {}, @@ -2548,7 +2548,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "722386109b88941c" + "id": "22ad7c8133ab52a6" }, { "metadata": {}, @@ -2560,7 +2560,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "553fa68944189fb4" + "id": "b467574c68647785" }, { "metadata": {}, @@ -2572,7 +2572,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "e6f8f50fafafc8eb" + "id": "f9528374dd35602" }, { "metadata": {}, @@ -2584,7 +2584,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "c7547bb93a3d1054" + "id": "e835ae85c2192ab7" }, { "metadata": {}, @@ -2597,7 +2597,7 @@ "\n", "cli.get('/link').text" ], - "id": "8462a0f064aff4d6" + "id": "deb69ba30f470540" }, { "metadata": {}, @@ -2614,7 +2614,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "3efa3323e4df7c5b" + "id": "1cfd1701ea7ebf0" }, { "metadata": {}, @@ -2622,7 +2622,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "f8e79041441126c8" + "id": "b2939ba999ab304d" }, { "metadata": {}, @@ -2652,7 +2652,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "97917c2da8c1b97" + "id": "4b8d1033d3464fad" }, { "metadata": {}, @@ -2666,7 +2666,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "32e87de40093ea3e" + "id": "5fbbf0ff97e14e2a" }, { "metadata": {}, @@ -2678,7 +2678,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "6e0388f8f7a33918" + "id": "eca17adf0acb8866" }, { "metadata": {}, @@ -2689,7 +2689,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "5b46014c78131c79" + "id": "90da5531747448d0" }, { "metadata": {}, @@ -2700,7 +2700,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "6a33532187a12204" + "id": "8415b3a9cd7170cb" }, { "metadata": {}, @@ -2726,7 +2726,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "9356fb7d26076190" + "id": "8f01e41745fe13d7" }, { "metadata": {}, @@ -2747,7 +2747,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "4aecb86c332772fd" + "id": "7eb22faa1169530d" }, { "metadata": {}, @@ -2764,7 +2764,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "23ee11e36fe61b4d" + "id": "f6ad3a65955e4912" }, { "metadata": {}, @@ -2772,7 +2772,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "6b7dbbccaee4786d" + "id": "3b3061f60d6234c9" }, { "metadata": {}, @@ -2780,7 +2780,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "125bbf08e83b1f3f" + "id": "a169cd1701f86e99" }, { "metadata": {}, @@ -2818,7 +2818,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "638477f27cdfea9f" + "id": "b45f94ae8e1b3d45" }, { "metadata": {}, @@ -2840,7 +2840,7 @@ " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" ], - "id": "17f7dacb62fbfc5a" + "id": "f9241c4eb8750c89" }, { "metadata": {}, @@ -2852,7 +2852,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "a96bea4a7d78778b" + "id": "f1c55e9acc001f63" }, { "metadata": {}, @@ -2863,7 +2863,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "337aae643cfc8ce8" + "id": "3320bec244df3d45" }, { "metadata": {}, @@ -2874,7 +2874,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "7a9bd99267318977" + "id": "bd1d304d67e4c472" }, { "metadata": {}, @@ -2885,7 +2885,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "329551bba246c150" + "id": "49d0bccc1ccfe5cf" }, { "metadata": {}, @@ -2900,7 +2900,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "fc23736b3a69099e" + "id": "84f8866042fe091c" }, { "metadata": {}, @@ -3028,7 +3028,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "4f4651a4a03e6bd7" + "id": "cda6412dd43e05d" }, { "metadata": {}, @@ -3036,7 +3036,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "4868272a396da7a" + "id": "6e8c3ef69026b65" }, { "metadata": {}, @@ -3051,7 +3051,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "45bedd85d15fea3" + "id": "271335192ff7f969" }, { "metadata": {}, @@ -3063,7 +3063,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "5b1d696b1bb0dfe8" + "id": "cbcaa44224244d95" }, { "metadata": {}, @@ -3085,7 +3085,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "89a74a9ed63b0864" + "id": "7922cb517577bb2" }, { "metadata": {}, @@ -3105,7 +3105,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "cdc8f3e6286abb12" + "id": "8caf0f8faa9a6c9b" }, { "metadata": {}, @@ -3118,7 +3118,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "872ee1028a67161c" + "id": "4d6997b9b07ec9e5" }, { "metadata": {}, @@ -3131,7 +3131,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "502441c79afad54e" + "id": "f4f728a30045a967" }, { "metadata": {}, @@ -3142,7 +3142,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "2f7203ecb23cd645" + "id": "2ad80628799d7b1e" }, { "metadata": {}, @@ -3150,7 +3150,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "bbb797a2ab879e64" + "id": "ed74e52b839522f9" }, { "metadata": {}, @@ -3166,7 +3166,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "42864ab8da13c1aa" + "id": "5c74eaf80939480a" }, { "metadata": {}, @@ -3182,7 +3182,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "5ef9c12c00a4749" + "id": "dc5baf4c4c3669fc" }, { "metadata": {}, @@ -3199,7 +3199,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "a95117e96c3ff27b" + "id": "95b87e9977521417" }, { "metadata": {}, @@ -3216,13 +3216,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "e227a2f8a4c65844" + "id": "d75860d528e2de43" }, { "metadata": {}, "cell_type": "markdown", "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", - "id": "6ac4871e67713519" + "id": "968f8c976a058b8d" }, { "metadata": {}, @@ -3230,7 +3230,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "94c99dda004100c5" + "id": "f0d4431471b871c3" }, { "metadata": {}, @@ -3259,13 +3259,13 @@ "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" ], - "id": "ad5580e80c3c6e36" + "id": "37bbd4f9b55c6dd1" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "2a65b631c66dc66a" + "id": "cd2b24dc8110566f" }, { "metadata": {}, @@ -3273,7 +3273,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "f510adb30a4c7ce4" + "id": "68c5206014ed9213" }, { "metadata": {}, @@ -3290,7 +3290,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "b5174061efb43755" + "id": "9d30176fe6ff5d80" }, { "metadata": {}, @@ -3308,13 +3308,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "d7095deb15659610" + "id": "b0c920a843b613ca" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "93c4dff4f2ab12b8" + "id": "b4a46b00c2dcd88f" }, { "metadata": {}, @@ -3336,13 +3336,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "80d1b584926749fe" + "id": "a5dfb4f864bf7a2" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "8f6b787dab0a55f9" + "id": "449253723791fb25" }, { "metadata": {}, @@ -3358,13 +3358,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "35912238f99ad24e" + "id": "f2b6d509f04311aa" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "281af529700a25a6" + "id": "3fe1127a6f080b6d" }, { "metadata": {}, @@ -3384,13 +3384,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "696c0126acb6e2" + "id": "b35d25e15f444d7a" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "5bd1d46a86fa8615" + "id": "ebc70142f2047605" }, { "metadata": {}, @@ -3406,7 +3406,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "8c810cdd71479bf9" + "id": "4da6765eb68d13f4" }, { "metadata": {}, @@ -3422,13 +3422,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "1b0fb60507f67d5e" + "id": "2b0bdd930e04183f" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "517da992945db2ca" + "id": "1bc91896b040461" }, { "metadata": {}, @@ -3453,13 +3453,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "cd3d9db054db1cd8" + "id": "53e7054eb1a2b950" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "cf11141fce1e452b" + "id": "b4ceeb36f249879b" }, { "metadata": {}, @@ -3478,7 +3478,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "9327c6c6df6513ac" + "id": "dd489489263448b8" }, { "metadata": {}, @@ -3526,7 +3526,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "b0f23ccd905b1549" + "id": "db0f1b7627ad8419" }, { "metadata": {}, @@ -3534,7 +3534,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "f2b3da6a3bdf4171" + "id": "80b5a7e8496e52a1" }, { "metadata": {}, @@ -3564,7 +3564,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "7114e29a3aabef4f" + "id": "1e4d360e237f4569" }, { "metadata": {}, @@ -3575,7 +3575,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "88dca6f4c2eb58c4" + "id": "3dfd2d46f750bb43" }, { "metadata": {}, @@ -3593,7 +3593,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "cc057dc0f8bee65" + "id": "f57009e52a85630e" }, { "metadata": {}, @@ -3614,7 +3614,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "518b2e5210f7fb61" + "id": "1607b6d6a5b0d8ee" }, { "metadata": {}, @@ -3627,7 +3627,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "9a02d8a1f576a12e" + "id": "4d54ef2ecc29d8dd" }, { "metadata": {}, @@ -3635,7 +3635,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "e8a620009a5610a2" + "id": "19bc679de5832f2" }, { "metadata": {}, @@ -3663,7 +3663,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "faa47549826cdb2a" + "id": "a249d4392561f908" }, { "metadata": {}, @@ -3674,7 +3674,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "8a0a82dd5dfd1479" + "id": "531f86e77c7a9e18" }, { "metadata": {}, @@ -3691,7 +3691,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "2ec82fec8f829227" + "id": "9015623a2d7ae2ac" }, { "metadata": {}, @@ -3710,7 +3710,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "b2d8b49bf2def958" + "id": "5a15d5ebe58b3ff9" }, { "metadata": {}, @@ -3723,7 +3723,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "2be2cf9204edd712" + "id": "9abbdc1aaa730fb" }, { "metadata": {}, @@ -3734,7 +3734,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "86ce3e2ac089558d" + "id": "cdc62291f85ce96f" }, { "metadata": {}, @@ -3756,13 +3756,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "d9484106682b7103" + "id": "1e7ea0dfcd8f11a2" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "5bf36467a1cf679" + "id": "db272805aca5a744" }, { "metadata": {}, @@ -3770,7 +3770,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "3a25293096797025" + "id": "5db1059c649cdeac" }, { "metadata": {}, @@ -3795,7 +3795,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "a95005810c174483" + "id": "bad36211af497c68" }, { "metadata": {}, @@ -3813,7 +3813,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "1cc5f8ac88e08c3a" + "id": "fef5ae760ae55872" }, { "metadata": {}, @@ -3827,7 +3827,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "6fe69203d6bfc884" + "id": "3954bd8bfd2f2ecd" }, { "metadata": {}, @@ -3847,7 +3847,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "577087d16b0a8d19" + "id": "78d0c14ed17fc9e1" }, { "metadata": {}, @@ -3862,7 +3862,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "cfb7108b7420e734" + "id": "58f7bae353e7173" }, { "metadata": {}, @@ -3873,7 +3873,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "1edc14249656a74" + "id": "1180c6ac96865b68" }, { "metadata": {}, @@ -3888,7 +3888,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "24d8e56439dadaff" + "id": "f321c722fc8562b3" }, { "metadata": {}, @@ -3899,7 +3899,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "eb0c931c74eaa1c2" + "id": "e9726e3fca4d7378" }, { "metadata": {}, @@ -3915,7 +3915,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "f4932f373f09a958" + "id": "936afd48e2034995" }, { "metadata": {}, @@ -3931,13 +3931,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "52c7a246c8adc736" + "id": "7cf64b21d9941d92" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "995f8b4492749588" + "id": "bf1392e596cc3f34" }, { "metadata": {}, @@ -3952,7 +3952,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "32806b81e81b7a90" + "id": "a44a8926c5672f3a" }, { "metadata": {}, @@ -3971,13 +3971,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "a718591914c37267" + "id": "c6923d192bff544f" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "320f98ff87461a78" + "id": "55a85e5b2288f4f3" }, { "metadata": {}, @@ -3997,7 +3997,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "3989aaa274753efa" + "id": "c3d25962e79bdb97" }, { "metadata": {}, @@ -4009,7 +4009,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "445f9099604c2004" + "id": "458e7853c5385e46" }, { "metadata": {}, @@ -4028,7 +4028,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "2aa1839a42ee118e" + "id": "ec14d8539a6de298" }, { "metadata": {}, @@ -4058,19 +4058,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "89ae3e66b54e884b" + "id": "1292600d44c5666d" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "d1cd9dce28dea2f4" + "id": "a64f441252ab7a98" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "56646c83620cb9d7" + "id": "9e03e8c9e9e29f46" }, { "metadata": {}, @@ -4086,13 +4086,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "aec9ef14145e0a6c" + "id": "d368de1619b46e2b" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "c3af92a37364ea5" + "id": "c48bff9150da29ba" }, { "metadata": {}, @@ -4104,13 +4104,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "2fba1dc094c12f8d" + "id": "2c9fac6a75a7682d" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "e9335b5696d455dd" + "id": "fa868932d03d34ea" }, { "metadata": {}, @@ -4122,7 +4122,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "be95c9878d5c00e8" + "id": "8c3b4c57f46a13ba" }, { "metadata": {}, @@ -4138,7 +4138,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "f514a8004cafc6fb" + "id": "4ae0e6c6a478afea" }, { "metadata": {}, @@ -4161,7 +4161,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "9cd4ca4bc9101e57" + "id": "b41769775e184688" }, { "metadata": {}, @@ -4181,13 +4181,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "d297d2a8e58b679b" + "id": "43b05eda046dd9b1" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "63be92ace16f29b" + "id": "82e40e9fc3e672be" }, { "metadata": {}, @@ -4208,13 +4208,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "5c959d6249ee4516" + "id": "25d1e4d40532b67d" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "5232230a1620cb96" + "id": "b31045273f818e5c" }, { "metadata": {}, @@ -4228,7 +4228,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "8e539b41580bba31" + "id": "20e7bff99122ff74" }, { "metadata": {}, @@ -4245,7 +4245,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "fe31b15b3bb1378d" + "id": "dfd77281d13e0ac2" }, { "metadata": {}, @@ -4261,7 +4261,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "2a8ce6fa46c64aef" + "id": "9b90a513c6b6898f" }, { "metadata": {}, @@ -4278,7 +4278,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "89533d87849a1d1c" + "id": "20c556c76f0b922b" }, { "metadata": {}, @@ -4293,7 +4293,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "77b7a333bc7aae11" + "id": "1e7b4a27dc3489a2" }, { "metadata": {}, @@ -4307,7 +4307,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "8e69a404c8e6513a" + "id": "84143777b3924517" }, { "metadata": {}, @@ -4327,13 +4327,13 @@ " app._send = send\n", " return send" ], - "id": "8a6671f803a4aa34" + "id": "ffd1d5f22445f36a" }, { "metadata": {}, "cell_type": "markdown", "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", - "id": "55954a5c03d669e4" + "id": "643aa0dfb0ed3533" }, { "metadata": {}, @@ -4350,7 +4350,7 @@ " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" ], - "id": "4c6e6a245ddc0e21" + "id": "3dcd7f3595e6f6c4" }, { "metadata": {}, @@ -4368,7 +4368,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "e73f8e2cf44bc34c" + "id": "8c0ac625aa372de6" }, { "metadata": {}, @@ -4388,7 +4388,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "f8bea79247120ca0" + "id": "4fe8cb775e9d9ef9" }, { "metadata": {}, @@ -4404,7 +4404,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "c21145b1f04a7374" + "id": "99972b72b8b074e" }, { "metadata": {}, @@ -4427,7 +4427,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "1b46d16bfa5c8fc3" + "id": "eaa0f719d8f20b07" }, { "metadata": {}, @@ -4438,13 +4438,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "1e19d16f00e6ef72" + "id": "58189856bed2c22e" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "8108539e4e0df8ab" + "id": "45c7ee609736a03d" }, { "metadata": {}, @@ -4456,7 +4456,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "f13f115109a75cc5" + "id": "b9758663fe4aa7fe" } ], "metadata": { From dc4ecbb15abd4661b6034a9d55181f4295f80689 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 17:06:04 -0300 Subject: [PATCH 14/22] refactor: update code cell structure --- nbs/api/00_core.ipynb | 578 +++++++++++++++++++++--------------------- 1 file changed, 289 insertions(+), 289 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 508b38c7..bdb9ea99 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "54673de215cc02ae" + "id": "3a4fe913bddf5377" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "5916dab6f96512a1" + "id": "7e6b51774b02e3f5" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "72dfc5f47fc2b065" + "id": "456e686efc9e98ca" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "dd3e843071e92d8e" + "id": "dcd2c7fcc4db8428" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "c67bdaa8a66843c6" + "id": "30411f64b8ce7b38" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "726d7eb2e547e91f" + "id": "145648c32bf3720c" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "aa24a7d24ef3a67f" + "id": "a469942d474f2223" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "d01470dc1f26eaab" + "id": "46625c94fd27ee85" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "154a3beb3539ad6e" + "id": "2e758a4aba5032fa" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "8e3db5510b7b730f" + "id": "d8a6352920139138" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "4c46c118110f5be1" + "id": "e17caf4bbe6decec" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "715e95820af9c1c7" + "id": "78830bcbd9499762" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "b264cb3b214b2ed1" + "id": "235be299ac5237e1" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "8e2c0d6026aca500" + "id": "2324ed32fbb90881" }, { "metadata": {}, @@ -240,7 +240,7 @@ " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "571a0464934df585" + "id": "654228afafaa9a7e" }, { "metadata": {}, @@ -255,7 +255,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "f54f730559c76812" + "id": "f9ae0bdab37df261" }, { "metadata": {}, @@ -266,7 +266,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "9005fbb397b9854d" + "id": "1b13f6f36c3a269a" }, { "metadata": {}, @@ -277,13 +277,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "100a7905b7d1eac6" + "id": "fce19b40f2a52f2d" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "c6f771ba2e739899" + "id": "917c5b6deea1236c" }, { "metadata": {}, @@ -294,7 +294,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "42a99dc9ab82725f" + "id": "8dfa868c7c848999" }, { "metadata": {}, @@ -310,7 +310,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "2180560267867106" + "id": "5056c8af15fcdd62" }, { "metadata": {}, @@ -331,7 +331,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "85f88140a3cdcee4" + "id": "1008d3634e85d5ea" }, { "metadata": {}, @@ -352,7 +352,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "33e76e53be7069c6" + "id": "b25dec11e2449d87" }, { "metadata": {}, @@ -370,7 +370,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "40fbbab8eaaf6658" + "id": "d4e94f66f51b0666" }, { "metadata": {}, @@ -383,7 +383,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "6fe7403e858b83c8" + "id": "91f2e4d457ce8ef4" }, { "metadata": {}, @@ -397,7 +397,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "1c1ab34e8e7246be" + "id": "789708ccb60fdd33" }, { "metadata": {}, @@ -411,7 +411,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "9e7b36dc9778ee69" + "id": "bf2a60dbe7e8c150" }, { "metadata": {}, @@ -419,7 +419,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "69afb3e3172a9254" + "id": "463f4eef2bbe59ba" }, { "metadata": {}, @@ -434,7 +434,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "c06896c5144a3804" + "id": "42a7a5ffa0a6e49c" }, { "metadata": {}, @@ -442,7 +442,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "c9b80df0c889c12f" + "id": "3e46722e7e5dd1ad" }, { "metadata": {}, @@ -456,7 +456,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "6eb074a576040794" + "id": "c7c4acf91602a735" }, { "metadata": {}, @@ -468,7 +468,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "ded2885cfd3dba08" + "id": "92579bfa178cf227" }, { "metadata": {}, @@ -479,7 +479,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "d336e12f9cc9550e" + "id": "57cfdd2952ccbc8f" }, { "metadata": {}, @@ -490,7 +490,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "11848044a63834a4" + "id": "137f40a4fa88bcb9" }, { "metadata": {}, @@ -506,7 +506,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "974f5b2ed3960fd5" + "id": "b8eeb3968501c182" }, { "metadata": {}, @@ -520,7 +520,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "2e4544782ea49be7" + "id": "a491c1df6027fdeb" }, { "metadata": {}, @@ -534,7 +534,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "dc31fbcbad56bec8" + "id": "a9f630dbcf938072" }, { "metadata": {}, @@ -545,7 +545,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "4848131291f46d6c" + "id": "3c829041fa82197f" }, { "metadata": {}, @@ -572,13 +572,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "2faa239b806247c4" + "id": "fc1b89fef3749042" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "2f75067ec21c2ae" + "id": "80a06b057b416ca3" }, { "metadata": {}, @@ -592,7 +592,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "6631ac703e8a731e" + "id": "3ee4a11c381bc02d" }, { "metadata": {}, @@ -606,7 +606,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "69139443ba149035" + "id": "d2f3b4c7a7d390d2" }, { "metadata": {}, @@ -630,7 +630,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "7f32f491183f452b" + "id": "168c584417e04a8" }, { "metadata": {}, @@ -647,13 +647,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "c8206d6bd0604a28" + "id": "e626ed90cf43dc1d" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "46979364e27f3113" + "id": "b6ad6d3549266d00" }, { "metadata": {}, @@ -669,7 +669,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "c5c96f3893f0e118" + "id": "d170d60def6c4c58" }, { "metadata": {}, @@ -690,7 +690,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "919bb9ccf0676e86" + "id": "43f5395af5a77ab0" }, { "metadata": {}, @@ -707,13 +707,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "398e38665eff97a8" + "id": "6db5c7af4fc48411" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "e64b2d109943f0a1" + "id": "ad4317cfb972d0e6" }, { "metadata": {}, @@ -726,13 +726,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "d2e3b81e73e289cd" + "id": "5203da945154781" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "aaca49fa25d143a7" + "id": "99b3ace9c77b21a2" }, { "metadata": {}, @@ -746,7 +746,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "5b64479106200ee4" + "id": "4aa4a3e08fd868c6" }, { "metadata": {}, @@ -758,7 +758,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "2afa078586f00b5e" + "id": "8abf550a9919f561" }, { "metadata": {}, @@ -826,7 +826,7 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "71a4c96e98f1a07e" + "id": "fc15e38511d9f444" }, { "metadata": {}, @@ -839,7 +839,7 @@ " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "bbff13b8e8648a68" + "id": "960d4cfe13b96b4c" }, { "metadata": {}, @@ -850,7 +850,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "80256940a8e5f387" + "id": "e661c3704f6c0ead" }, { "metadata": {}, @@ -863,7 +863,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "7fe7f9e6dd9e0fd4" + "id": "9efbe0faad1b88ff" }, { "metadata": {}, @@ -883,7 +883,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "7c1bf4fd9f4eef6a" + "id": "6e70667069825c92" }, { "metadata": {}, @@ -901,7 +901,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "1a846dd3bac66088" + "id": "a9261e0c52f7bdb7" }, { "metadata": {}, @@ -919,7 +919,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "4608aeb4fa1d7dcb" + "id": "33ffb1199b162088" }, { "metadata": {}, @@ -941,19 +941,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "96e204c9cdc7b7d4" + "id": "7261b847c18524b3" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "b96b04e856f4b4df" + "id": "64d68bed7e4dfb92" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "6cf70210a9865783" + "id": "970b6cb91002038f" }, { "metadata": {}, @@ -971,13 +971,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "7b9108615e5d341d" + "id": "dd6d1ba5ca9cbf54" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "e75abc668fd0a60f" + "id": "daee65331c6638fd" }, { "metadata": {}, @@ -995,7 +995,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "3bd58f90d130f5ae" + "id": "304616ab801df3da" }, { "metadata": {}, @@ -1013,7 +1013,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "7f07a85cea1bfda7" + "id": "7604932c2feb0f10" }, { "metadata": {}, @@ -1025,7 +1025,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "d03f02e4056a5f46" + "id": "94c81ca38ddd11a2" }, { "metadata": {}, @@ -1039,7 +1039,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "333d104176d958e8" + "id": "80bf5343cb876283" }, { "metadata": {}, @@ -1051,7 +1051,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "c28d4b7161a95fca" + "id": "95e77692bfb63dbd" }, { "metadata": {}, @@ -1062,13 +1062,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "3243918e0476c605" + "id": "33d39142404f800d" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "ea9d54f85dde85c5" + "id": "1dd8f4b40f3eec85" }, { "metadata": {}, @@ -1082,7 +1082,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "65d3fb37a299d758" + "id": "66632d6be04915cf" }, { "metadata": {}, @@ -1118,7 +1118,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "ecd29a00bb3a3c14" + "id": "917931e30f9dce6d" }, { "metadata": {}, @@ -1134,7 +1134,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "6374e98d7c93d8b1" + "id": "b9b5506c18a25486" }, { "metadata": {}, @@ -1149,7 +1149,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "b79cd55107a4ab92" + "id": "1836a3b2aaed4651" }, { "metadata": {}, @@ -1162,7 +1162,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "c5cad1b69635cc05" + "id": "887844e62b52b876" }, { "metadata": {}, @@ -1182,13 +1182,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "75b3a0aa7a47a1b7" + "id": "fd15f520e8231a15" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "f07dedce5d9bcf38" + "id": "ac0d04f19c60d029" }, { "metadata": {}, @@ -1201,7 +1201,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "13d6c0097988ab92" + "id": "61b8e17ef2a4a0d3" }, { "metadata": {}, @@ -1215,7 +1215,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "3709e60a7cc8184e" + "id": "d9ab14f2bcb3170b" }, { "metadata": {}, @@ -1226,7 +1226,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "77981132c364e4cc" + "id": "c6c08065e44e5372" }, { "metadata": {}, @@ -1244,7 +1244,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "f774a63aabdadefd" + "id": "9d6ac43e904d8ff9" }, { "metadata": {}, @@ -1258,7 +1258,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "6aae8831fe46c17a" + "id": "2b05e4d728f27886" }, { "metadata": {}, @@ -1292,7 +1292,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "8d8cd62e6ee3f425" + "id": "cd55cacdcbe91f29" }, { "metadata": {}, @@ -1303,7 +1303,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "dd4ac3049bb2acb0" + "id": "55548b2a51819105" }, { "metadata": {}, @@ -1322,7 +1322,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "e88e3588b714065a" + "id": "8c5ce61b750a1ba1" }, { "metadata": {}, @@ -1335,7 +1335,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "58be48f27d142ebd" + "id": "cb42dac98a842d46" }, { "metadata": {}, @@ -1352,7 +1352,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "6431603002304136" + "id": "2bf246b39d1830b3" }, { "metadata": {}, @@ -1361,7 +1361,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "552cf8818d74e78f" + "id": "34b1239c3b161d2e" }, { "metadata": {}, @@ -1375,7 +1375,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "232d36b4dd20ed08" + "id": "ae0d1f6043c72679" }, { "metadata": {}, @@ -1400,7 +1400,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "5eef360551d25c43" + "id": "1ca80633085ac527" }, { "metadata": {}, @@ -1424,7 +1424,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "2c118553dfa340c1" + "id": "1f9333f82fd9dbc9" }, { "metadata": {}, @@ -1437,7 +1437,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "1deab5e82c1adc72" + "id": "a59ff6d783a9de60" }, { "metadata": {}, @@ -1465,7 +1465,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "7632e8aeb7ed3c51" + "id": "afe9ebe56fcf6fdb" }, { "metadata": {}, @@ -1481,7 +1481,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "1f84dbac3a368799" + "id": "9d62d010f4799faa" }, { "metadata": {}, @@ -1495,7 +1495,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "f9f093d21633f122" + "id": "176cc939908f6ccc" }, { "metadata": {}, @@ -1523,13 +1523,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "81fbfb6af3ef7069" + "id": "f9689ce94d4cccb9" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "f743a1d64e20d8b4" + "id": "df31e386b2538000" }, { "metadata": {}, @@ -1537,7 +1537,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "165c3137837907b6" + "id": "e260c041d3cd69af" }, { "metadata": {}, @@ -1554,7 +1554,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "9a3946272ac28f58" + "id": "d91c64ce9daa06aa" }, { "metadata": {}, @@ -1572,7 +1572,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "bf8f889c5daafb34" + "id": "93fd762df151846d" }, { "metadata": {}, @@ -1585,7 +1585,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "aff6b95d0192c21d" + "id": "95cc6f4b02514de4" }, { "metadata": {}, @@ -1603,7 +1603,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "5bcd45457b91f54b" + "id": "3e5d5556d82aa5e4" }, { "metadata": {}, @@ -1623,13 +1623,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "558d58d3c3542441" + "id": "91730a4351dd7f69" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "1e5101c35e818308" + "id": "d445dd2e0fd9256e" }, { "metadata": {}, @@ -1637,7 +1637,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "a3e4cbc388fa9c1b" + "id": "10958d64201a3eb7" }, { "metadata": {}, @@ -1648,13 +1648,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "2c9a4f6013d24b55" + "id": "313a91b89bb010d6" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "cbdf99bf51fb04ef" + "id": "d85f1592c7207304" }, { "metadata": {}, @@ -1666,7 +1666,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "15cb338ec7bd902f" + "id": "e62c47ae0803f6e0" }, { "metadata": {}, @@ -1687,7 +1687,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "885036e117ea4b5a" + "id": "a8dd2eed82432d62" }, { "metadata": {}, @@ -1720,7 +1720,7 @@ " };\n", "\"\"\"))\n" ], - "id": "c29093b6a137a596" + "id": "20272a32f556dee8" }, { "metadata": {}, @@ -1755,7 +1755,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "8620dead102b860a" + "id": "b071fa3cf751f72b" }, { "metadata": {}, @@ -1802,13 +1802,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "1e1edb3b5fb58c5" + "id": "a4e0cdf37762178b" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "df09f97471ee33bd" + "id": "9ef79a49ad49bf12" }, { "metadata": {}, @@ -1834,7 +1834,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "18a853f06439ae5b" + "id": "f356ea1e26767ac1" }, { "metadata": {}, @@ -1854,7 +1854,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "d0da83816779ea5d" + "id": "b717e6760ebdcbbb" }, { "metadata": {}, @@ -1870,7 +1870,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "2d0f3ce87f487b7" + "id": "56dcef412dd394ae" }, { "metadata": {}, @@ -1889,7 +1889,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "cb5ee740fd53ee17" + "id": "8a9e1b412ec9c07f" }, { "metadata": {}, @@ -1900,7 +1900,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "5802575f24b7e70b" + "id": "4f6d490f150573b9" }, { "metadata": {}, @@ -1937,7 +1937,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "d866ea4ab96589d9" + "id": "f2bca14683c59b00" }, { "metadata": {}, @@ -1955,7 +1955,7 @@ " self.add_route(route)\n", " return func" ], - "id": "c470c908745b7cf3" + "id": "847b937a80bbac3" }, { "metadata": {}, @@ -1970,7 +1970,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "2146b4be1a97821d" + "id": "e676d4681addb69b" }, { "metadata": {}, @@ -1984,7 +1984,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "df18d9fa22f571f1" + "id": "db9aa4931e0a766f" }, { "metadata": {}, @@ -2006,7 +2006,7 @@ "\n", " return _lf()" ], - "id": "db956d8ef2284772" + "id": "e1dc652656c5f31" }, { "metadata": {}, @@ -2019,7 +2019,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "f9ca5ed307e59042" + "id": "f071a1e024d76b5f" }, { "metadata": {}, @@ -2031,7 +2031,7 @@ " def g(): ...\n", " return g" ], - "id": "3049386a7b3d0bd3" + "id": "79ad3d78243e9760" }, { "metadata": {}, @@ -2042,7 +2042,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "84f01125b5865bf5" + "id": "d546cb425b566180" }, { "metadata": {}, @@ -2062,7 +2062,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "deb6de6d7af52f36" + "id": "2f35f7ad8a11c648" }, { "metadata": {}, @@ -2095,7 +2095,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "901a4cdc6bf2cc7a" + "id": "5a249c6f63e4a639" }, { "metadata": {}, @@ -2113,7 +2113,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "cf1e52f55047b225" + "id": "324858f2d75d2de7" }, { "metadata": {}, @@ -2127,7 +2127,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "f1156a4b59658096" + "id": "8aef19cdc2aa8184" }, { "metadata": {}, @@ -2140,13 +2140,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "c41eaf5a89ed6453" + "id": "1870944f201016a1" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "d38c086bbb205515" + "id": "2d493b2949c2b24e" }, { "metadata": {}, @@ -2161,7 +2161,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "20cbcdcaa6db1234" + "id": "80df6e654d0d896a" }, { "metadata": {}, @@ -2195,13 +2195,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "f08636dac1efdae7" + "id": "7d82b672484f7454" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "1603490fee1cd916" + "id": "4cd1a1e692f2209b" }, { "metadata": {}, @@ -2216,13 +2216,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "13ed96bb4c14ca11" + "id": "14d95e05909d938b" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "3f926352a5fb9f35" + "id": "19bb6d0b64152373" }, { "metadata": {}, @@ -2245,13 +2245,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "3f538226afa6ae27" + "id": "c51f8c6c201f5cb7" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "653b6ecf5a4b8633" + "id": "7662d99b73b1eacd" }, { "metadata": {}, @@ -2273,7 +2273,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "fcd6b50abcdf8e93" + "id": "2bb905a642893470" }, { "metadata": {}, @@ -2294,7 +2294,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "603310edfe21d02e" + "id": "484fcb9f31dfedf9" }, { "metadata": {}, @@ -2307,19 +2307,19 @@ "\n", "cli.get('/').text" ], - "id": "d30975982ffa1d87" + "id": "f41886244d277389" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "8c6958ad8ea4de61" + "id": "d0733d785d137613" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "f29127574a568d19" + "id": "c73101ed20e90a1e" }, { "metadata": {}, @@ -2327,7 +2327,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "3a4529333e1c3c34" + "id": "227782bbe2bc3116" }, { "metadata": {}, @@ -2335,7 +2335,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "c7fc061f1affd76e" + "id": "f213582a8727390e" }, { "metadata": {}, @@ -2354,7 +2354,7 @@ "\n", "foo.to(param='value')" ], - "id": "27979f751d1fd36e" + "id": "9c7b041c8b2e6ccb" }, { "metadata": {}, @@ -2370,7 +2370,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "a4732af26da7fd8" + "id": "f63668c1f9eb16cb" }, { "metadata": {}, @@ -2383,7 +2383,7 @@ "\n", "foo.to(param='value')" ], - "id": "bf0e0d7ebae1009c" + "id": "96bcadf7c0009c2" }, { "metadata": {}, @@ -2399,7 +2399,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "2218fb53eff7b52f" + "id": "c8c8f9601a6805d9" }, { "metadata": {}, @@ -2411,7 +2411,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "d48456ce6cddb90d" + "id": "c560ec70b932e8a0" }, { "metadata": {}, @@ -2433,7 +2433,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "e897e7712a32d21d" + "id": "99f062fb2db11a0b" }, { "metadata": {}, @@ -2448,7 +2448,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "b184bb689cd8a0ee" + "id": "5037ceb2f9675730" }, { "metadata": {}, @@ -2462,7 +2462,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "9758b81739fa81a" + "id": "c4b089da74a8c756" }, { "metadata": {}, @@ -2475,7 +2475,7 @@ "\n", "cli.post('/hi').text" ], - "id": "67fe24f6f0bcfec" + "id": "4fbd8e8a7c9a7b27" }, { "metadata": {}, @@ -2488,7 +2488,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "4164e132e048c91" + "id": "127910b5e7929054" }, { "metadata": {}, @@ -2511,7 +2511,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "c15b8e7b7638573c" + "id": "c75448d74af39b78" }, { "metadata": {}, @@ -2524,7 +2524,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "8239a9ed9b968de8" + "id": "c49852ec082ad5c0" }, { "metadata": {}, @@ -2536,7 +2536,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "fac64641823ab881" + "id": "198d78e4408712f6" }, { "metadata": {}, @@ -2548,7 +2548,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "22ad7c8133ab52a6" + "id": "93eee8a5aab96ad" }, { "metadata": {}, @@ -2560,7 +2560,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "b467574c68647785" + "id": "cff1ac7ae6ee7150" }, { "metadata": {}, @@ -2572,7 +2572,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "f9528374dd35602" + "id": "d401f4c763c3322d" }, { "metadata": {}, @@ -2584,7 +2584,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "e835ae85c2192ab7" + "id": "6a8121cf7ae43ccc" }, { "metadata": {}, @@ -2597,7 +2597,7 @@ "\n", "cli.get('/link').text" ], - "id": "deb69ba30f470540" + "id": "3fbdefe8d480500d" }, { "metadata": {}, @@ -2614,7 +2614,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "1cfd1701ea7ebf0" + "id": "75df97e99571d063" }, { "metadata": {}, @@ -2622,7 +2622,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "b2939ba999ab304d" + "id": "6681fc800d4f2404" }, { "metadata": {}, @@ -2652,7 +2652,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "4b8d1033d3464fad" + "id": "53e3358555f3fa29" }, { "metadata": {}, @@ -2666,7 +2666,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "5fbbf0ff97e14e2a" + "id": "26e52aa869d8a835" }, { "metadata": {}, @@ -2678,7 +2678,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "eca17adf0acb8866" + "id": "c0908eb8205c9684" }, { "metadata": {}, @@ -2689,7 +2689,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "90da5531747448d0" + "id": "2bfe4683487a33c0" }, { "metadata": {}, @@ -2700,7 +2700,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "8415b3a9cd7170cb" + "id": "a6d01737948c2693" }, { "metadata": {}, @@ -2726,7 +2726,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "8f01e41745fe13d7" + "id": "8e96b95edb70c1f2" }, { "metadata": {}, @@ -2747,7 +2747,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "7eb22faa1169530d" + "id": "c9864e13c80c9d06" }, { "metadata": {}, @@ -2764,7 +2764,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "f6ad3a65955e4912" + "id": "237663e88bc28765" }, { "metadata": {}, @@ -2772,7 +2772,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "3b3061f60d6234c9" + "id": "3dd801d6f5b4f240" }, { "metadata": {}, @@ -2780,7 +2780,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "a169cd1701f86e99" + "id": "61f450753bd42b2d" }, { "metadata": {}, @@ -2818,7 +2818,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "b45f94ae8e1b3d45" + "id": "7a57d33fda9a8a2b" }, { "metadata": {}, @@ -2840,7 +2840,7 @@ " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" ], - "id": "f9241c4eb8750c89" + "id": "ac5b5eafe67374c5" }, { "metadata": {}, @@ -2852,7 +2852,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "f1c55e9acc001f63" + "id": "1baffc883ff349e2" }, { "metadata": {}, @@ -2863,7 +2863,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "3320bec244df3d45" + "id": "20d4c2e05f4683db" }, { "metadata": {}, @@ -2874,7 +2874,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "bd1d304d67e4c472" + "id": "22ce7c507c4258a" }, { "metadata": {}, @@ -2885,7 +2885,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "49d0bccc1ccfe5cf" + "id": "d76eb65bc4f8f582" }, { "metadata": {}, @@ -2900,7 +2900,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "84f8866042fe091c" + "id": "645ac32a56a22104" }, { "metadata": {}, @@ -3028,7 +3028,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "cda6412dd43e05d" + "id": "c04233ceddc37a08" }, { "metadata": {}, @@ -3036,7 +3036,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "6e8c3ef69026b65" + "id": "ff1b49b820298e23" }, { "metadata": {}, @@ -3051,7 +3051,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "271335192ff7f969" + "id": "eef7e1bade1b2f6" }, { "metadata": {}, @@ -3063,7 +3063,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "cbcaa44224244d95" + "id": "b8382e120076f834" }, { "metadata": {}, @@ -3085,7 +3085,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "7922cb517577bb2" + "id": "33e814ac5046234f" }, { "metadata": {}, @@ -3105,7 +3105,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "8caf0f8faa9a6c9b" + "id": "9b4eb7ae6fa98035" }, { "metadata": {}, @@ -3118,7 +3118,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "4d6997b9b07ec9e5" + "id": "d6c4081ed81d58e" }, { "metadata": {}, @@ -3131,7 +3131,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "f4f728a30045a967" + "id": "dd5efc20a3036cfb" }, { "metadata": {}, @@ -3142,7 +3142,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "2ad80628799d7b1e" + "id": "292e57dcd0c413c2" }, { "metadata": {}, @@ -3150,7 +3150,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "ed74e52b839522f9" + "id": "c0d83190aa8003aa" }, { "metadata": {}, @@ -3166,7 +3166,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "5c74eaf80939480a" + "id": "afe82acea26a0d5f" }, { "metadata": {}, @@ -3182,7 +3182,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "dc5baf4c4c3669fc" + "id": "ad6bb704c4c2cb01" }, { "metadata": {}, @@ -3199,7 +3199,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "95b87e9977521417" + "id": "b4c69adf3560c324" }, { "metadata": {}, @@ -3216,13 +3216,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "d75860d528e2de43" + "id": "64a24cff281ec1f9" }, { "metadata": {}, "cell_type": "markdown", "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", - "id": "968f8c976a058b8d" + "id": "48599eba2a6f8585" }, { "metadata": {}, @@ -3230,7 +3230,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "f0d4431471b871c3" + "id": "c86f91b4dc39ffa2" }, { "metadata": {}, @@ -3259,13 +3259,13 @@ "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" ], - "id": "37bbd4f9b55c6dd1" + "id": "681a7ef7830885f5" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "cd2b24dc8110566f" + "id": "216c917091f9d1b1" }, { "metadata": {}, @@ -3273,7 +3273,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "68c5206014ed9213" + "id": "7c0a34fe34662f74" }, { "metadata": {}, @@ -3290,7 +3290,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "9d30176fe6ff5d80" + "id": "411755fcf92d04e" }, { "metadata": {}, @@ -3308,13 +3308,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "b0c920a843b613ca" + "id": "fd513368500668a5" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "b4a46b00c2dcd88f" + "id": "7a9a33b29832a916" }, { "metadata": {}, @@ -3336,13 +3336,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "a5dfb4f864bf7a2" + "id": "afc00fdb22d03427" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "449253723791fb25" + "id": "b81764e9228cfaca" }, { "metadata": {}, @@ -3358,13 +3358,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "f2b6d509f04311aa" + "id": "b4845c4d18c151ac" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "3fe1127a6f080b6d" + "id": "9863675cdaf79bfb" }, { "metadata": {}, @@ -3384,13 +3384,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "b35d25e15f444d7a" + "id": "4bb09ad281c14715" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "ebc70142f2047605" + "id": "8cc3d4d9a0dbc5bb" }, { "metadata": {}, @@ -3406,7 +3406,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "4da6765eb68d13f4" + "id": "6ce08ffca334781a" }, { "metadata": {}, @@ -3422,13 +3422,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "2b0bdd930e04183f" + "id": "ac67fc4681aefaec" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "1bc91896b040461" + "id": "b7ecf82fa734d0c1" }, { "metadata": {}, @@ -3453,13 +3453,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "53e7054eb1a2b950" + "id": "55b8299a826cffd8" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "b4ceeb36f249879b" + "id": "2c550df7571accd5" }, { "metadata": {}, @@ -3478,7 +3478,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "dd489489263448b8" + "id": "5662dd140161cb02" }, { "metadata": {}, @@ -3526,7 +3526,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "db0f1b7627ad8419" + "id": "ea9e7f2c79e54ed0" }, { "metadata": {}, @@ -3534,7 +3534,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "80b5a7e8496e52a1" + "id": "182b472aeb12afe9" }, { "metadata": {}, @@ -3564,7 +3564,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "1e4d360e237f4569" + "id": "6e54651b164f1e47" }, { "metadata": {}, @@ -3575,7 +3575,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "3dfd2d46f750bb43" + "id": "d96156739b1d125f" }, { "metadata": {}, @@ -3593,7 +3593,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "f57009e52a85630e" + "id": "c77b15a2d8b31cad" }, { "metadata": {}, @@ -3614,7 +3614,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "1607b6d6a5b0d8ee" + "id": "393bccdc28100f4d" }, { "metadata": {}, @@ -3627,7 +3627,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "4d54ef2ecc29d8dd" + "id": "738c4d7c85d40c71" }, { "metadata": {}, @@ -3635,7 +3635,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "19bc679de5832f2" + "id": "d42187122c19f2ac" }, { "metadata": {}, @@ -3663,7 +3663,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "a249d4392561f908" + "id": "5a4b34c452ea2b94" }, { "metadata": {}, @@ -3674,7 +3674,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "531f86e77c7a9e18" + "id": "af226633435d4ebb" }, { "metadata": {}, @@ -3691,7 +3691,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "9015623a2d7ae2ac" + "id": "15d32d4996f2b05b" }, { "metadata": {}, @@ -3710,7 +3710,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "5a15d5ebe58b3ff9" + "id": "983a9794868ae9e4" }, { "metadata": {}, @@ -3723,7 +3723,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "9abbdc1aaa730fb" + "id": "f9671d0eb2a22114" }, { "metadata": {}, @@ -3734,7 +3734,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "cdc62291f85ce96f" + "id": "cc3bb74ebd0cc8fd" }, { "metadata": {}, @@ -3756,13 +3756,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "1e7ea0dfcd8f11a2" + "id": "5e9dca3108424e33" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "db272805aca5a744" + "id": "f706651ebcc5e23f" }, { "metadata": {}, @@ -3770,7 +3770,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "5db1059c649cdeac" + "id": "bed8456590527092" }, { "metadata": {}, @@ -3795,7 +3795,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "bad36211af497c68" + "id": "495b4e49686d94d8" }, { "metadata": {}, @@ -3813,7 +3813,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "fef5ae760ae55872" + "id": "e8c017b17a0aed6a" }, { "metadata": {}, @@ -3827,7 +3827,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "3954bd8bfd2f2ecd" + "id": "f996765eafd96149" }, { "metadata": {}, @@ -3847,7 +3847,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "78d0c14ed17fc9e1" + "id": "41304b4320061eea" }, { "metadata": {}, @@ -3862,7 +3862,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "58f7bae353e7173" + "id": "6f57512153c8243a" }, { "metadata": {}, @@ -3873,7 +3873,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "1180c6ac96865b68" + "id": "f62c008587df43fb" }, { "metadata": {}, @@ -3888,7 +3888,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "f321c722fc8562b3" + "id": "c91e11dd189e13f" }, { "metadata": {}, @@ -3899,7 +3899,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "e9726e3fca4d7378" + "id": "90c4352ef8068b22" }, { "metadata": {}, @@ -3915,7 +3915,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "936afd48e2034995" + "id": "6d819e4887e2eb0b" }, { "metadata": {}, @@ -3931,13 +3931,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "7cf64b21d9941d92" + "id": "d3c7730e0883ce01" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "bf1392e596cc3f34" + "id": "bd5fbbd0abbf3b33" }, { "metadata": {}, @@ -3952,7 +3952,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "a44a8926c5672f3a" + "id": "a1ef2ce8026bc4e2" }, { "metadata": {}, @@ -3971,13 +3971,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "c6923d192bff544f" + "id": "54bd13f1ebca82fc" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "55a85e5b2288f4f3" + "id": "4d76603228ded3ca" }, { "metadata": {}, @@ -3997,7 +3997,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "c3d25962e79bdb97" + "id": "da194bbe3fd84479" }, { "metadata": {}, @@ -4009,7 +4009,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "458e7853c5385e46" + "id": "d77fd7cbfd4bbc48" }, { "metadata": {}, @@ -4028,7 +4028,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "ec14d8539a6de298" + "id": "81aafd43c39b7881" }, { "metadata": {}, @@ -4058,19 +4058,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "1292600d44c5666d" + "id": "7026475c42408580" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "a64f441252ab7a98" + "id": "4fc47abb18c8d447" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "9e03e8c9e9e29f46" + "id": "f451b2cfdca829b2" }, { "metadata": {}, @@ -4086,13 +4086,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "d368de1619b46e2b" + "id": "3287a130a616117d" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "c48bff9150da29ba" + "id": "f28bcc9e1dc06295" }, { "metadata": {}, @@ -4104,13 +4104,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "2c9fac6a75a7682d" + "id": "7845e020efe2738d" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "fa868932d03d34ea" + "id": "e4800ea1f3bf3e4a" }, { "metadata": {}, @@ -4122,7 +4122,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "8c3b4c57f46a13ba" + "id": "b9bbf1c1d81466c7" }, { "metadata": {}, @@ -4138,7 +4138,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "4ae0e6c6a478afea" + "id": "b4a86f5ae2bfb1a1" }, { "metadata": {}, @@ -4161,7 +4161,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "b41769775e184688" + "id": "349ee9b339676814" }, { "metadata": {}, @@ -4181,13 +4181,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "43b05eda046dd9b1" + "id": "b61a0aa10bb2f7a4" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "82e40e9fc3e672be" + "id": "eb6a1d399227fd58" }, { "metadata": {}, @@ -4208,13 +4208,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "25d1e4d40532b67d" + "id": "6cce4906d8a6d20e" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "b31045273f818e5c" + "id": "256f22c5b2e0e761" }, { "metadata": {}, @@ -4228,7 +4228,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "20e7bff99122ff74" + "id": "4c5e4a39073c459b" }, { "metadata": {}, @@ -4245,7 +4245,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "dfd77281d13e0ac2" + "id": "eb4ecc823eec1798" }, { "metadata": {}, @@ -4261,7 +4261,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "9b90a513c6b6898f" + "id": "41fac73250771b0f" }, { "metadata": {}, @@ -4278,7 +4278,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "20c556c76f0b922b" + "id": "da11a84b0d5ded0b" }, { "metadata": {}, @@ -4293,7 +4293,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "1e7b4a27dc3489a2" + "id": "faaca6fc6926d27" }, { "metadata": {}, @@ -4307,7 +4307,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "84143777b3924517" + "id": "90c98deaa4ad8ffa" }, { "metadata": {}, @@ -4327,13 +4327,13 @@ " app._send = send\n", " return send" ], - "id": "ffd1d5f22445f36a" + "id": "52ac2b17945fa720" }, { "metadata": {}, "cell_type": "markdown", "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", - "id": "643aa0dfb0ed3533" + "id": "89bbe46ad24bf334" }, { "metadata": {}, @@ -4350,7 +4350,7 @@ " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" ], - "id": "3dcd7f3595e6f6c4" + "id": "cd1e16ba0668eba" }, { "metadata": {}, @@ -4368,7 +4368,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "8c0ac625aa372de6" + "id": "3a82c1052cdf37e2" }, { "metadata": {}, @@ -4388,7 +4388,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "4fe8cb775e9d9ef9" + "id": "ca7cbd53eb13747e" }, { "metadata": {}, @@ -4404,7 +4404,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "99972b72b8b074e" + "id": "e205251ebab7ba0c" }, { "metadata": {}, @@ -4427,7 +4427,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "eaa0f719d8f20b07" + "id": "287ea611bc84670f" }, { "metadata": {}, @@ -4438,13 +4438,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "58189856bed2c22e" + "id": "c5d8aacb66ceddeb" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "45c7ee609736a03d" + "id": "dbdbcf414f16a199" }, { "metadata": {}, @@ -4456,7 +4456,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "b9758663fe4aa7fe" + "id": "962d5c44e34841e" } ], "metadata": { From a4b8255880c068832fbd7b379bb63d32e456bce8 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 15:57:07 -0300 Subject: [PATCH 15/22] feat: introduce Alias class for handling request parameter aliases. close #937 --- fasthtml/core.py | 74 ++---- nbs/api/00_core.ipynb | 590 +++++++++++++++++++++--------------------- 2 files changed, 323 insertions(+), 341 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index d7ac66f3..44ea3997 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -48,11 +48,11 @@ class Cards: ... # class route group: each verb-named method (get, patch, . __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', - 'JSONResponse', 'Alias', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', - 'flat_tuple', 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', - 'FastHTML', 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', - 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', - 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] + 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', + 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', + 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', 'RouteFuncs', + 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', + 'MiddlewareBase', 'FtResponse', 'unqid'] # %% ../nbs/api/00_core.ipynb #23503b9e import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess @@ -64,7 +64,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from fastcore.style import S from types import UnionType, SimpleNamespace as ns, GenericAlias -from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated +from typing import get_args, get_origin, Union, Mapping, List, Any, Callable from datetime import datetime,date from dataclasses import dataclass from inspect import Parameter,get_annotations @@ -119,14 +119,8 @@ class HtmxHeaders: trigger_name:str|None=None; trigger:str|None=None def __bool__(self): return any(hasattr(self,o) for o in htmx_hdrs) -def _get_header(h, k): - k = k.lower() - res = h.get(k) - encoded = h.get(k+'-uri-autoencoded')=='true' or (k=='hx-prompt' and h.get('hx-request-type')) - return unquote(res) if res is not None and encoded else res - def _get_htmx(h): - res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()} + res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()} return HtmxHeaders(**res) # %% ../nbs/api/00_core.ipynb #1d53e8e7 @@ -196,8 +190,7 @@ def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_r # %% ../nbs/api/00_core.ipynb #ffdde66f def _formitem(form, k): - "Return item `k`, accepting hyphens for underscores and unwrapping single-item form lists" - if k not in form: k = {o.replace('-', '_'):o for o in form}.get(k, k) + "Return single item `k` from `form` if len 1, otherwise return list" if isinstance(form, dict): return form.get(k) o = form.getlist(k) return o[0] if len(o) == 1 else o if o else None @@ -242,7 +235,6 @@ async def _from_body(conn, p, data): kwargs = await _find_ps(conn, data, conn.headers, ps) return await maybe_await(ctor(**kwargs)) d = _annotations(anno) - if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d} return anno(**cargs) @@ -265,19 +257,8 @@ def _default(o): return list(o) if is_listy(o) else str(o) # %% ../nbs/api/00_core.ipynb #5fa96e3a -@dataclass(frozen=True) -class Alias: - "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" - name: str - async def _find_p(conn, data, hdrs, arg:str, p:Parameter): "In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)" - alias = None - if get_origin(p.annotation) is Annotated: - anno, *meta = get_args(p.annotation) - p = p.replace(annotation=anno) - alias = next((m.name for m in meta if isinstance(m, Alias)), None) - key = alias or arg anno = p.annotation # Special annotation types if isinstance(anno, type) and not isinstance(anno, GenericAlias): @@ -287,7 +268,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if issubclass(anno, State): return conn.scope['app'].state if anno is dict: return data if _is_body(anno): - if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) + if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) return await _from_body(conn, p, data) if (msg := _check_anno(arg, anno)): return warn(msg) # Special param names with no annotations @@ -308,16 +289,17 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) return None # Not a special name or a special annotation - res = conn.path_params.get(key, None) - if res in (empty,None): res = conn.cookies.get(key, None) - if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key)) - if res in (empty,None): res = _formitem(conn.query_params, key) - if res in (empty,None): res = _formitem(data, key) - if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` + res = conn.path_params.get(arg, None) + if res in (empty,None): res = conn.cookies.get(arg, None) + if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None) + if res in (empty,None): res = conn.query_params.getlist(arg) + if res==[]: res = None + if res in (empty,None): res = data.get(arg, None) + if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(arg) # htmx v4 nests form data under `values` if res in (empty,None): if p.default is empty: - if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {key}") - raise ValueError(f"Missing required field: {key}") + if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") + raise ValueError(f"Missing required field: {arg}") res = p.default try: return _fix_anno(anno, res) except ValueError as e: @@ -580,17 +562,17 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "ws4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-ws.min.js", + "ws4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-ws.min.js", "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", - "sse4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-sse.min.js", - "live": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-live.min.js", - "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js" + "sse4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js", + "live": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js", + "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js" } # %% ../nbs/api/00_core.ipynb #60cb52ea htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") -htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/htmx.min.js") -fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.13/fasthtml.js") +htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js") +fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") viewport = Meta(name="viewport", content="width=device-width, initial-scale=1, viewport-fit=cover") @@ -638,7 +620,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] if surreal: hdrs = [surrsrc,scopesrc] + hdrs - if htmx and htmx4: + if htmx4: # metaCharacter="-" makes htmx4 use dashes instead of colons meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"})) hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs @@ -709,7 +691,7 @@ def __init__(self, debug=False, routes=None, middleware=None, title: str = "Fast same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey', body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw): middleware,before,after = map(_list, (middleware,before,after)) - self.htmx4,self.title,self.canonical,self.session_cookie,self.key_fname = htmx4,title,canonical,session_cookie,key_fname + self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts)) if htmx4 and exts: exts = ['ws4' if e in ('ws', 'ws4') else e for e in exts] exts = {k:htmx_exts[k] for k in exts} @@ -1172,8 +1154,8 @@ def _add_ids(s): @patch def setup_ws(app:FastHTML, f=noop): conns = {} - async def on_connect(ws, send): conns[ws.client] = send - async def on_disconnect(ws): conns.pop(ws.client) + async def on_connect(scope, send): conns[scope.client] = send + async def on_disconnect(scope): conns.pop(scope.client) app.ws('/ws', conn=on_connect, disconn=on_disconnect)(f) async def send(s): for o in conns.values(): await o(s) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index bdb9ea99..1a055895 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "3a4fe913bddf5377" + "id": "6b087a4c2f3b6d9" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "7e6b51774b02e3f5" + "id": "f7667c70b7d81829" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "456e686efc9e98ca" + "id": "9213151fd0f7268a" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "dcd2c7fcc4db8428" + "id": "9c390fef75f5b02b" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "30411f64b8ce7b38" + "id": "d1c03a9eead53dfc" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "145648c32bf3720c" + "id": "9dba06f8752c292" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "a469942d474f2223" + "id": "162e763547af118a" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "46625c94fd27ee85" + "id": "d5b03052784eb7e9" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "2e758a4aba5032fa" + "id": "9464aced18c6348e" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "d8a6352920139138" + "id": "e12c5103529c1a4c" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "e17caf4bbe6decec" + "id": "7f348157e8cc2eec" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "78830bcbd9499762" + "id": "248c7b4c118d787a" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "235be299ac5237e1" + "id": "94ae65a29ffd1b95" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "2324ed32fbb90881" + "id": "5219a5d0fcd51fcb" }, { "metadata": {}, @@ -240,7 +240,7 @@ " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "654228afafaa9a7e" + "id": "ace54d35fea9e313" }, { "metadata": {}, @@ -255,7 +255,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "f9ae0bdab37df261" + "id": "ca611168acf94812" }, { "metadata": {}, @@ -266,7 +266,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "1b13f6f36c3a269a" + "id": "992e5cad97c5c3cd" }, { "metadata": {}, @@ -277,13 +277,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "fce19b40f2a52f2d" + "id": "deaa2bfa6d7f91a9" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "917c5b6deea1236c" + "id": "c7b828289addaed7" }, { "metadata": {}, @@ -294,7 +294,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "8dfa868c7c848999" + "id": "162370aa5851cefd" }, { "metadata": {}, @@ -310,7 +310,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "5056c8af15fcdd62" + "id": "6579d47c7653d929" }, { "metadata": {}, @@ -331,7 +331,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "1008d3634e85d5ea" + "id": "4be94e3e0b89994d" }, { "metadata": {}, @@ -352,7 +352,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "b25dec11e2449d87" + "id": "857a9ec7b5844ecb" }, { "metadata": {}, @@ -370,7 +370,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "d4e94f66f51b0666" + "id": "4fcf32555ef2dd23" }, { "metadata": {}, @@ -383,7 +383,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "91f2e4d457ce8ef4" + "id": "91f51da1537218ff" }, { "metadata": {}, @@ -397,7 +397,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "789708ccb60fdd33" + "id": "c0e50b316a0a23c7" }, { "metadata": {}, @@ -411,7 +411,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "bf2a60dbe7e8c150" + "id": "2010d881ca470c0c" }, { "metadata": {}, @@ -419,7 +419,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "463f4eef2bbe59ba" + "id": "c2fa630f926f98f" }, { "metadata": {}, @@ -434,7 +434,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "42a7a5ffa0a6e49c" + "id": "fb895c514dd736d3" }, { "metadata": {}, @@ -442,7 +442,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "3e46722e7e5dd1ad" + "id": "e4d4a7958ba129af" }, { "metadata": {}, @@ -456,7 +456,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "c7c4acf91602a735" + "id": "a9a6eda0eb6d2e95" }, { "metadata": {}, @@ -468,7 +468,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "92579bfa178cf227" + "id": "ae30d43af1a70dfc" }, { "metadata": {}, @@ -479,7 +479,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "57cfdd2952ccbc8f" + "id": "2a1122eca66b3f99" }, { "metadata": {}, @@ -490,7 +490,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "137f40a4fa88bcb9" + "id": "30a0914c08301daa" }, { "metadata": {}, @@ -506,7 +506,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "b8eeb3968501c182" + "id": "62e1d62281ef7a5f" }, { "metadata": {}, @@ -520,7 +520,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "a491c1df6027fdeb" + "id": "d5018c181d420d6" }, { "metadata": {}, @@ -534,7 +534,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "a9f630dbcf938072" + "id": "aa68ff13406341e1" }, { "metadata": {}, @@ -545,7 +545,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "3c829041fa82197f" + "id": "befba0a90394a295" }, { "metadata": {}, @@ -572,13 +572,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "fc1b89fef3749042" + "id": "8240407c2c069f23" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "80a06b057b416ca3" + "id": "e39fcff53ef7c916" }, { "metadata": {}, @@ -592,7 +592,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "3ee4a11c381bc02d" + "id": "23296f7391670991" }, { "metadata": {}, @@ -606,7 +606,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "d2f3b4c7a7d390d2" + "id": "1cb3267894e8df34" }, { "metadata": {}, @@ -630,7 +630,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "168c584417e04a8" + "id": "375f94ed06202e8" }, { "metadata": {}, @@ -647,13 +647,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "e626ed90cf43dc1d" + "id": "b3170b82c360a59a" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "b6ad6d3549266d00" + "id": "129f03948158cdeb" }, { "metadata": {}, @@ -669,7 +669,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "d170d60def6c4c58" + "id": "1b35de78c9b8a2a1" }, { "metadata": {}, @@ -690,7 +690,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "43f5395af5a77ab0" + "id": "c5b410f7e773cde6" }, { "metadata": {}, @@ -707,13 +707,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "6db5c7af4fc48411" + "id": "1432f9f0fd614451" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "ad4317cfb972d0e6" + "id": "8ee85b7abc1340af" }, { "metadata": {}, @@ -726,13 +726,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "5203da945154781" + "id": "13a9d36e017a4ee4" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "99b3ace9c77b21a2" + "id": "e98066f74286dfc7" }, { "metadata": {}, @@ -746,7 +746,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "4aa4a3e08fd868c6" + "id": "fea0c687c459a8d3" }, { "metadata": {}, @@ -758,7 +758,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "8abf550a9919f561" + "id": "eb1bd8fabfb95c55" }, { "metadata": {}, @@ -826,7 +826,7 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "fc15e38511d9f444" + "id": "c9f331ae9caa9f09" }, { "metadata": {}, @@ -834,12 +834,10 @@ "outputs": [], "execution_count": null, "source": [ - "#| export\n", - "async def _find_ps(conn, data, hdrs, params):\n", - " if conn.query_params: data |= dict(conn.query_params)\n", - " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" + "data = form2dict(await parse_form(req))\n", + "await _from_body(req, p, data)" ], - "id": "960d4cfe13b96b4c" + "id": "37c41d951f82ab7" }, { "metadata": {}, @@ -847,10 +845,12 @@ "outputs": [], "execution_count": null, "source": [ - "data = form2dict(await parse_form(req))\n", - "await _from_body(req, p, data)" + "#| export\n", + "async def _find_ps(conn, data, hdrs, params):\n", + " if conn.query_params: data |= dict(conn.query_params)\n", + " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "e661c3704f6c0ead" + "id": "14c7bab54ee7455a" }, { "metadata": {}, @@ -863,7 +863,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "9efbe0faad1b88ff" + "id": "de508c22e1e259b7" }, { "metadata": {}, @@ -883,7 +883,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "6e70667069825c92" + "id": "822004231e7b2a78" }, { "metadata": {}, @@ -901,7 +901,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "a9261e0c52f7bdb7" + "id": "2104d7d51d7d5be6" }, { "metadata": {}, @@ -919,7 +919,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "33ffb1199b162088" + "id": "14279e29697f5c49" }, { "metadata": {}, @@ -941,19 +941,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "7261b847c18524b3" + "id": "aaa4d7aea1bca190" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "64d68bed7e4dfb92" + "id": "d40bb428e237d024" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "970b6cb91002038f" + "id": "233a2d6f4a051e34" }, { "metadata": {}, @@ -971,13 +971,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "dd6d1ba5ca9cbf54" + "id": "cf738f52ab4f9d85" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "daee65331c6638fd" + "id": "57ffd3192662a354" }, { "metadata": {}, @@ -995,7 +995,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "304616ab801df3da" + "id": "8c4f65da2f11665c" }, { "metadata": {}, @@ -1013,7 +1013,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "7604932c2feb0f10" + "id": "d7c73fda488e2f32" }, { "metadata": {}, @@ -1025,7 +1025,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "94c81ca38ddd11a2" + "id": "48615c51b5269cbb" }, { "metadata": {}, @@ -1039,7 +1039,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "80bf5343cb876283" + "id": "d78574431e97d0a1" }, { "metadata": {}, @@ -1051,7 +1051,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "95e77692bfb63dbd" + "id": "646b27d23679375b" }, { "metadata": {}, @@ -1062,13 +1062,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "33d39142404f800d" + "id": "cfad1edd6b247c2f" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "1dd8f4b40f3eec85" + "id": "2d65b63da1b33be8" }, { "metadata": {}, @@ -1082,7 +1082,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "66632d6be04915cf" + "id": "5b49eaf5bdd26f83" }, { "metadata": {}, @@ -1118,7 +1118,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "917931e30f9dce6d" + "id": "7e65187ac312a2ea" }, { "metadata": {}, @@ -1134,7 +1134,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "b9b5506c18a25486" + "id": "21d8006298a4cf2e" }, { "metadata": {}, @@ -1149,7 +1149,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "1836a3b2aaed4651" + "id": "93a8009e2b7703a3" }, { "metadata": {}, @@ -1162,7 +1162,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "887844e62b52b876" + "id": "d73a13761300014e" }, { "metadata": {}, @@ -1182,13 +1182,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "fd15f520e8231a15" + "id": "175ea5578da116b0" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "ac0d04f19c60d029" + "id": "7e55ed8326a992fe" }, { "metadata": {}, @@ -1201,7 +1201,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "61b8e17ef2a4a0d3" + "id": "e54063a6bf41b99a" }, { "metadata": {}, @@ -1215,7 +1215,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "d9ab14f2bcb3170b" + "id": "4b7ffc98448e22ee" }, { "metadata": {}, @@ -1226,7 +1226,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "c6c08065e44e5372" + "id": "2cf3348466d5813a" }, { "metadata": {}, @@ -1244,7 +1244,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "9d6ac43e904d8ff9" + "id": "29a8876ff03063c5" }, { "metadata": {}, @@ -1258,7 +1258,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "2b05e4d728f27886" + "id": "ff7a9a97c9d7c9" }, { "metadata": {}, @@ -1292,7 +1292,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "cd55cacdcbe91f29" + "id": "78e9ee2ca2d17066" }, { "metadata": {}, @@ -1303,7 +1303,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "55548b2a51819105" + "id": "811b7e5e79ad27ca" }, { "metadata": {}, @@ -1322,7 +1322,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "8c5ce61b750a1ba1" + "id": "3dca2824752e328a" }, { "metadata": {}, @@ -1335,7 +1335,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "cb42dac98a842d46" + "id": "9fe638d89bdfc7b6" }, { "metadata": {}, @@ -1352,7 +1352,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "2bf246b39d1830b3" + "id": "c114afe116abd19b" }, { "metadata": {}, @@ -1361,7 +1361,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "34b1239c3b161d2e" + "id": "e93bd07a3436cd58" }, { "metadata": {}, @@ -1375,7 +1375,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "ae0d1f6043c72679" + "id": "1d6c5a66b3840fa3" }, { "metadata": {}, @@ -1400,7 +1400,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "1ca80633085ac527" + "id": "c973a0536f0de4bd" }, { "metadata": {}, @@ -1424,7 +1424,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "1f9333f82fd9dbc9" + "id": "dcceeb6fcbe5609b" }, { "metadata": {}, @@ -1437,7 +1437,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "a59ff6d783a9de60" + "id": "3d190d387e2848a7" }, { "metadata": {}, @@ -1465,7 +1465,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "afe9ebe56fcf6fdb" + "id": "79a2137a019d18e1" }, { "metadata": {}, @@ -1481,7 +1481,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "9d62d010f4799faa" + "id": "9d3c4545eb48d34b" }, { "metadata": {}, @@ -1495,7 +1495,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "176cc939908f6ccc" + "id": "b9c898549e6a8742" }, { "metadata": {}, @@ -1523,13 +1523,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "f9689ce94d4cccb9" + "id": "13c285a049a19b07" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "df31e386b2538000" + "id": "378e7313411a86ab" }, { "metadata": {}, @@ -1537,7 +1537,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "e260c041d3cd69af" + "id": "2507df810418d1be" }, { "metadata": {}, @@ -1554,7 +1554,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "d91c64ce9daa06aa" + "id": "64f094449284adc7" }, { "metadata": {}, @@ -1572,7 +1572,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "93fd762df151846d" + "id": "a83c8bb972021a02" }, { "metadata": {}, @@ -1585,7 +1585,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "95cc6f4b02514de4" + "id": "35b621c392cc9f1c" }, { "metadata": {}, @@ -1603,7 +1603,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "3e5d5556d82aa5e4" + "id": "dcb6cb371bb29c26" }, { "metadata": {}, @@ -1623,13 +1623,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "91730a4351dd7f69" + "id": "a926802b3069b4c3" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "d445dd2e0fd9256e" + "id": "cdebefbfd6b10b33" }, { "metadata": {}, @@ -1637,7 +1637,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "10958d64201a3eb7" + "id": "b8d362c3a74677b8" }, { "metadata": {}, @@ -1648,13 +1648,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "313a91b89bb010d6" + "id": "5087bf5dd17f278e" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "d85f1592c7207304" + "id": "1b41b3e7202c18da" }, { "metadata": {}, @@ -1666,7 +1666,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "e62c47ae0803f6e0" + "id": "5f980bcbb4b4b0ad" }, { "metadata": {}, @@ -1687,7 +1687,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "a8dd2eed82432d62" + "id": "8832bd1c92a8fc8a" }, { "metadata": {}, @@ -1720,7 +1720,7 @@ " };\n", "\"\"\"))\n" ], - "id": "20272a32f556dee8" + "id": "cc9b30de224c5c3" }, { "metadata": {}, @@ -1755,7 +1755,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "b071fa3cf751f72b" + "id": "382c793b6b152466" }, { "metadata": {}, @@ -1802,13 +1802,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "a4e0cdf37762178b" + "id": "966dd501e1982d59" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "9ef79a49ad49bf12" + "id": "c3b475687fb43a88" }, { "metadata": {}, @@ -1834,7 +1834,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "f356ea1e26767ac1" + "id": "3280dfb123c18a90" }, { "metadata": {}, @@ -1854,7 +1854,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "b717e6760ebdcbbb" + "id": "4ca38fd401dc9d9c" }, { "metadata": {}, @@ -1870,7 +1870,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "56dcef412dd394ae" + "id": "5f0d5a0c7650934c" }, { "metadata": {}, @@ -1889,7 +1889,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "8a9e1b412ec9c07f" + "id": "4f72e30a87e158c4" }, { "metadata": {}, @@ -1900,7 +1900,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "4f6d490f150573b9" + "id": "b8c6bf06ebd5e608" }, { "metadata": {}, @@ -1937,7 +1937,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "f2bca14683c59b00" + "id": "282b1ea137d75fb7" }, { "metadata": {}, @@ -1955,7 +1955,7 @@ " self.add_route(route)\n", " return func" ], - "id": "847b937a80bbac3" + "id": "257f07d08a7a4c59" }, { "metadata": {}, @@ -1970,7 +1970,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "e676d4681addb69b" + "id": "61b58f14808f749e" }, { "metadata": {}, @@ -1984,7 +1984,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "db9aa4931e0a766f" + "id": "f85125edeb9aed1a" }, { "metadata": {}, @@ -2006,7 +2006,7 @@ "\n", " return _lf()" ], - "id": "e1dc652656c5f31" + "id": "f50faaeb6f73b5de" }, { "metadata": {}, @@ -2019,7 +2019,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "f071a1e024d76b5f" + "id": "a390c87b1a3aec49" }, { "metadata": {}, @@ -2031,7 +2031,7 @@ " def g(): ...\n", " return g" ], - "id": "79ad3d78243e9760" + "id": "36452e19bd9117c8" }, { "metadata": {}, @@ -2042,7 +2042,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "d546cb425b566180" + "id": "f056d9ffe477b31c" }, { "metadata": {}, @@ -2062,7 +2062,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "2f35f7ad8a11c648" + "id": "c45c1c68184bc4a1" }, { "metadata": {}, @@ -2095,7 +2095,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "5a249c6f63e4a639" + "id": "f5adf4c3b9a7aad5" }, { "metadata": {}, @@ -2113,7 +2113,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "324858f2d75d2de7" + "id": "2374467745be1179" }, { "metadata": {}, @@ -2127,7 +2127,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "8aef19cdc2aa8184" + "id": "e35e13178b84f7cc" }, { "metadata": {}, @@ -2140,13 +2140,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "1870944f201016a1" + "id": "84afe4dac3bf8aad" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "2d493b2949c2b24e" + "id": "657dc2babbae2fb5" }, { "metadata": {}, @@ -2161,7 +2161,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "80df6e654d0d896a" + "id": "ea4b2542501d9ced" }, { "metadata": {}, @@ -2195,13 +2195,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "7d82b672484f7454" + "id": "b43f8e1941124912" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "4cd1a1e692f2209b" + "id": "1f2e847ef0a4e15f" }, { "metadata": {}, @@ -2216,13 +2216,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "14d95e05909d938b" + "id": "a3560e79d446ebbd" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "19bb6d0b64152373" + "id": "b40c6ad1eb96881e" }, { "metadata": {}, @@ -2245,13 +2245,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "c51f8c6c201f5cb7" + "id": "78d477b49a363620" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "7662d99b73b1eacd" + "id": "8ad28f83ca13f972" }, { "metadata": {}, @@ -2273,7 +2273,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "2bb905a642893470" + "id": "ac941cbdf42263d6" }, { "metadata": {}, @@ -2294,7 +2294,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "484fcb9f31dfedf9" + "id": "a2b670c85a0f928d" }, { "metadata": {}, @@ -2307,19 +2307,19 @@ "\n", "cli.get('/').text" ], - "id": "f41886244d277389" + "id": "a844cc6f1446b208" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "d0733d785d137613" + "id": "9fb31f83e54b0af9" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "c73101ed20e90a1e" + "id": "24cebc9cb006db4c" }, { "metadata": {}, @@ -2327,7 +2327,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "227782bbe2bc3116" + "id": "ca0a971b999f71f1" }, { "metadata": {}, @@ -2335,7 +2335,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "f213582a8727390e" + "id": "8866284cfe216dbe" }, { "metadata": {}, @@ -2354,7 +2354,7 @@ "\n", "foo.to(param='value')" ], - "id": "9c7b041c8b2e6ccb" + "id": "c63c83b1e684dbf7" }, { "metadata": {}, @@ -2370,7 +2370,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "f63668c1f9eb16cb" + "id": "ec586e255abbc6af" }, { "metadata": {}, @@ -2383,7 +2383,7 @@ "\n", "foo.to(param='value')" ], - "id": "96bcadf7c0009c2" + "id": "fd173cb012d52ece" }, { "metadata": {}, @@ -2399,7 +2399,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "c8c8f9601a6805d9" + "id": "e0adfa68c66ef60c" }, { "metadata": {}, @@ -2411,7 +2411,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "c560ec70b932e8a0" + "id": "eb993344e8db44fb" }, { "metadata": {}, @@ -2433,7 +2433,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "99f062fb2db11a0b" + "id": "4f64195cb491d9a" }, { "metadata": {}, @@ -2448,7 +2448,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "5037ceb2f9675730" + "id": "3a3e5d71137fef00" }, { "metadata": {}, @@ -2462,7 +2462,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "c4b089da74a8c756" + "id": "28c418860e99d889" }, { "metadata": {}, @@ -2475,7 +2475,7 @@ "\n", "cli.post('/hi').text" ], - "id": "4fbd8e8a7c9a7b27" + "id": "393e1dfe598c2000" }, { "metadata": {}, @@ -2488,7 +2488,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "127910b5e7929054" + "id": "78f18ac1e1fb5247" }, { "metadata": {}, @@ -2511,7 +2511,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "c75448d74af39b78" + "id": "4d4bb52a2b1a3c3c" }, { "metadata": {}, @@ -2524,7 +2524,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "c49852ec082ad5c0" + "id": "6bb2986d68edeb2a" }, { "metadata": {}, @@ -2536,7 +2536,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "198d78e4408712f6" + "id": "c48df52d1ee142a2" }, { "metadata": {}, @@ -2548,7 +2548,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "93eee8a5aab96ad" + "id": "2be1f568f78c1cf5" }, { "metadata": {}, @@ -2560,7 +2560,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "cff1ac7ae6ee7150" + "id": "79cc4ac388e7316e" }, { "metadata": {}, @@ -2572,7 +2572,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "d401f4c763c3322d" + "id": "38bd2b5ebd6c27c6" }, { "metadata": {}, @@ -2584,7 +2584,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "6a8121cf7ae43ccc" + "id": "103a5c8ab391984a" }, { "metadata": {}, @@ -2597,7 +2597,7 @@ "\n", "cli.get('/link').text" ], - "id": "3fbdefe8d480500d" + "id": "52b2a8491cfa46d9" }, { "metadata": {}, @@ -2614,7 +2614,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "75df97e99571d063" + "id": "cf0cb70cf404894e" }, { "metadata": {}, @@ -2622,7 +2622,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "6681fc800d4f2404" + "id": "3bbe7501a91bde53" }, { "metadata": {}, @@ -2652,7 +2652,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "53e3358555f3fa29" + "id": "1c66d8ab2e4c648e" }, { "metadata": {}, @@ -2666,7 +2666,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "26e52aa869d8a835" + "id": "499f68654b6c02f4" }, { "metadata": {}, @@ -2678,7 +2678,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "c0908eb8205c9684" + "id": "2f7f848752d3511f" }, { "metadata": {}, @@ -2689,7 +2689,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "2bfe4683487a33c0" + "id": "39a9706c030f062" }, { "metadata": {}, @@ -2700,7 +2700,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "a6d01737948c2693" + "id": "975b42c39630221f" }, { "metadata": {}, @@ -2726,7 +2726,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "8e96b95edb70c1f2" + "id": "c1933296ec534a96" }, { "metadata": {}, @@ -2747,7 +2747,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "c9864e13c80c9d06" + "id": "42ee25f3094d5f56" }, { "metadata": {}, @@ -2764,7 +2764,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "237663e88bc28765" + "id": "6706a5a4586b02df" }, { "metadata": {}, @@ -2772,7 +2772,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "3dd801d6f5b4f240" + "id": "db66baccd052ca38" }, { "metadata": {}, @@ -2780,7 +2780,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "61f450753bd42b2d" + "id": "6c58923204adf869" }, { "metadata": {}, @@ -2818,7 +2818,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "7a57d33fda9a8a2b" + "id": "20bcff436b5de63f" }, { "metadata": {}, @@ -2840,7 +2840,7 @@ " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" ], - "id": "ac5b5eafe67374c5" + "id": "933deee287c91160" }, { "metadata": {}, @@ -2852,7 +2852,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "1baffc883ff349e2" + "id": "672e209a43efc9b3" }, { "metadata": {}, @@ -2863,7 +2863,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "20d4c2e05f4683db" + "id": "87ffd1d735c4a1ee" }, { "metadata": {}, @@ -2874,7 +2874,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "22ce7c507c4258a" + "id": "a195bd32bcdf8ea5" }, { "metadata": {}, @@ -2885,7 +2885,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "d76eb65bc4f8f582" + "id": "8068f1fc5e8881df" }, { "metadata": {}, @@ -2900,7 +2900,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "645ac32a56a22104" + "id": "d9de5b5313d8e13b" }, { "metadata": {}, @@ -3028,7 +3028,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "c04233ceddc37a08" + "id": "4794698bf5a86106" }, { "metadata": {}, @@ -3036,7 +3036,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "ff1b49b820298e23" + "id": "c567b8dfe94a859e" }, { "metadata": {}, @@ -3051,7 +3051,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "eef7e1bade1b2f6" + "id": "46fae77110bad117" }, { "metadata": {}, @@ -3063,7 +3063,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "b8382e120076f834" + "id": "8c708286d4dfc336" }, { "metadata": {}, @@ -3085,7 +3085,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "33e814ac5046234f" + "id": "791daea606af38be" }, { "metadata": {}, @@ -3105,7 +3105,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "9b4eb7ae6fa98035" + "id": "21f9df3fdc6ce225" }, { "metadata": {}, @@ -3118,7 +3118,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "d6c4081ed81d58e" + "id": "9e16a3ee272d4865" }, { "metadata": {}, @@ -3131,7 +3131,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "dd5efc20a3036cfb" + "id": "e136f2047b968402" }, { "metadata": {}, @@ -3142,7 +3142,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "292e57dcd0c413c2" + "id": "c0e27a587d5d685b" }, { "metadata": {}, @@ -3150,7 +3150,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "c0d83190aa8003aa" + "id": "f2564de290e329c" }, { "metadata": {}, @@ -3166,7 +3166,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "afe82acea26a0d5f" + "id": "42a31940160cac97" }, { "metadata": {}, @@ -3182,7 +3182,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "ad6bb704c4c2cb01" + "id": "266a8d862a4cfde9" }, { "metadata": {}, @@ -3199,7 +3199,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "b4c69adf3560c324" + "id": "a683d712db56188a" }, { "metadata": {}, @@ -3216,13 +3216,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "64a24cff281ec1f9" + "id": "8e27ab3b6d63a36a" }, { "metadata": {}, "cell_type": "markdown", "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", - "id": "48599eba2a6f8585" + "id": "f6e5316b2603036c" }, { "metadata": {}, @@ -3230,7 +3230,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "c86f91b4dc39ffa2" + "id": "452ce94aed456532" }, { "metadata": {}, @@ -3259,13 +3259,13 @@ "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" ], - "id": "681a7ef7830885f5" + "id": "6db3d836c31699b6" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "216c917091f9d1b1" + "id": "8df5c1eff76d21f7" }, { "metadata": {}, @@ -3273,7 +3273,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "7c0a34fe34662f74" + "id": "513c5251ca32ab98" }, { "metadata": {}, @@ -3290,7 +3290,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "411755fcf92d04e" + "id": "12bb98ee628f5c29" }, { "metadata": {}, @@ -3308,13 +3308,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "fd513368500668a5" + "id": "ceddb20f4f628803" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "7a9a33b29832a916" + "id": "fcc30d146e8b33d4" }, { "metadata": {}, @@ -3336,13 +3336,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "afc00fdb22d03427" + "id": "3719ca72ca32854f" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "b81764e9228cfaca" + "id": "d5bf6a93b3583775" }, { "metadata": {}, @@ -3358,13 +3358,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "b4845c4d18c151ac" + "id": "7588d17481dd8440" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "9863675cdaf79bfb" + "id": "874cf832d6cf48c5" }, { "metadata": {}, @@ -3384,13 +3384,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "4bb09ad281c14715" + "id": "b87d5db7952eb2b1" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "8cc3d4d9a0dbc5bb" + "id": "5f5e0830b720dce0" }, { "metadata": {}, @@ -3406,7 +3406,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "6ce08ffca334781a" + "id": "9ae5f58d1d44af2a" }, { "metadata": {}, @@ -3422,13 +3422,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "ac67fc4681aefaec" + "id": "692a70fe9d83ae1d" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "b7ecf82fa734d0c1" + "id": "44245a632212c6aa" }, { "metadata": {}, @@ -3453,13 +3453,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "55b8299a826cffd8" + "id": "708c14c7c1d8204b" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "2c550df7571accd5" + "id": "ab850e2d804c43f" }, { "metadata": {}, @@ -3478,7 +3478,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "5662dd140161cb02" + "id": "fbefbbdc5fac6a43" }, { "metadata": {}, @@ -3526,7 +3526,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "ea9e7f2c79e54ed0" + "id": "1219db3b16b794a" }, { "metadata": {}, @@ -3534,7 +3534,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "182b472aeb12afe9" + "id": "2a3357acbbb20fb1" }, { "metadata": {}, @@ -3564,7 +3564,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "6e54651b164f1e47" + "id": "5fe7fd73f9c1d5f8" }, { "metadata": {}, @@ -3575,7 +3575,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "d96156739b1d125f" + "id": "393adba61d24eda5" }, { "metadata": {}, @@ -3593,7 +3593,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "c77b15a2d8b31cad" + "id": "fd226f94e283e666" }, { "metadata": {}, @@ -3614,7 +3614,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "393bccdc28100f4d" + "id": "519cb8c2d0b204bf" }, { "metadata": {}, @@ -3627,7 +3627,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "738c4d7c85d40c71" + "id": "2f3234ab80266cc1" }, { "metadata": {}, @@ -3635,7 +3635,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "d42187122c19f2ac" + "id": "a08ee86a54f77995" }, { "metadata": {}, @@ -3663,7 +3663,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "5a4b34c452ea2b94" + "id": "95a34335abeff730" }, { "metadata": {}, @@ -3674,7 +3674,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "af226633435d4ebb" + "id": "df4539565827cd9" }, { "metadata": {}, @@ -3691,7 +3691,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "15d32d4996f2b05b" + "id": "bab08c9701bd9b3e" }, { "metadata": {}, @@ -3710,7 +3710,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "983a9794868ae9e4" + "id": "8ccbea2f28bac1be" }, { "metadata": {}, @@ -3723,7 +3723,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "f9671d0eb2a22114" + "id": "4ca461d0bc3b874e" }, { "metadata": {}, @@ -3734,7 +3734,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "cc3bb74ebd0cc8fd" + "id": "792b54853bf957db" }, { "metadata": {}, @@ -3756,13 +3756,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "5e9dca3108424e33" + "id": "af400a3ee4144c60" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "f706651ebcc5e23f" + "id": "bc37f41babbbcbb6" }, { "metadata": {}, @@ -3770,7 +3770,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "bed8456590527092" + "id": "bf336e455698c7f2" }, { "metadata": {}, @@ -3795,7 +3795,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "495b4e49686d94d8" + "id": "b6468658a9949251" }, { "metadata": {}, @@ -3813,7 +3813,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "e8c017b17a0aed6a" + "id": "bcef5541adc3b76" }, { "metadata": {}, @@ -3827,7 +3827,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "f996765eafd96149" + "id": "3eae31c3ef7ae39a" }, { "metadata": {}, @@ -3847,7 +3847,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "41304b4320061eea" + "id": "9333a8aa3131fa87" }, { "metadata": {}, @@ -3862,7 +3862,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "6f57512153c8243a" + "id": "15c876f8ea8489f6" }, { "metadata": {}, @@ -3873,7 +3873,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "f62c008587df43fb" + "id": "2d0b1f454422b863" }, { "metadata": {}, @@ -3888,7 +3888,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "c91e11dd189e13f" + "id": "d8e388d5d93c353f" }, { "metadata": {}, @@ -3899,7 +3899,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "90c4352ef8068b22" + "id": "41723eb1c447498d" }, { "metadata": {}, @@ -3915,7 +3915,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "6d819e4887e2eb0b" + "id": "f8b812a4bee5f891" }, { "metadata": {}, @@ -3931,13 +3931,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "d3c7730e0883ce01" + "id": "f327ec92e995ed39" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "bd5fbbd0abbf3b33" + "id": "d3dd20bae3fdbcad" }, { "metadata": {}, @@ -3952,7 +3952,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "a1ef2ce8026bc4e2" + "id": "9511541bb4b118c" }, { "metadata": {}, @@ -3971,13 +3971,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "54bd13f1ebca82fc" + "id": "573837cc71fe6744" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "4d76603228ded3ca" + "id": "9f148c9306ee68ba" }, { "metadata": {}, @@ -3997,7 +3997,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "da194bbe3fd84479" + "id": "78e1b9d2adef8930" }, { "metadata": {}, @@ -4009,7 +4009,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "d77fd7cbfd4bbc48" + "id": "df646f0b1a35d2d4" }, { "metadata": {}, @@ -4028,7 +4028,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "81aafd43c39b7881" + "id": "5bfeb75f765f7f80" }, { "metadata": {}, @@ -4058,19 +4058,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "7026475c42408580" + "id": "c41bf6d621614ca8" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "4fc47abb18c8d447" + "id": "a5714587cedabe" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "f451b2cfdca829b2" + "id": "d4bcc99873df7a1b" }, { "metadata": {}, @@ -4086,13 +4086,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "3287a130a616117d" + "id": "d030291042cf0a9" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "f28bcc9e1dc06295" + "id": "ea80c7fd37667f14" }, { "metadata": {}, @@ -4104,13 +4104,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "7845e020efe2738d" + "id": "e44f15a4e0b8b6d5" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "e4800ea1f3bf3e4a" + "id": "8b3d12e0c319e7c6" }, { "metadata": {}, @@ -4122,7 +4122,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "b9bbf1c1d81466c7" + "id": "38376fa297413604" }, { "metadata": {}, @@ -4138,7 +4138,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "b4a86f5ae2bfb1a1" + "id": "2ad0002dd43d7df5" }, { "metadata": {}, @@ -4161,7 +4161,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "349ee9b339676814" + "id": "8b985c121ddebe48" }, { "metadata": {}, @@ -4181,13 +4181,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "b61a0aa10bb2f7a4" + "id": "510d518764e4313b" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "eb6a1d399227fd58" + "id": "6d57edf2b50510af" }, { "metadata": {}, @@ -4208,13 +4208,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "6cce4906d8a6d20e" + "id": "6e2a806f070b7933" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "256f22c5b2e0e761" + "id": "23d87040990382d" }, { "metadata": {}, @@ -4228,7 +4228,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "4c5e4a39073c459b" + "id": "d481195695540e06" }, { "metadata": {}, @@ -4245,7 +4245,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "eb4ecc823eec1798" + "id": "c76db0d654c4399b" }, { "metadata": {}, @@ -4261,7 +4261,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "41fac73250771b0f" + "id": "80a1cb212c505d99" }, { "metadata": {}, @@ -4278,7 +4278,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "da11a84b0d5ded0b" + "id": "bf5640016cdb04dc" }, { "metadata": {}, @@ -4293,7 +4293,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "faaca6fc6926d27" + "id": "aefa7050c3afef64" }, { "metadata": {}, @@ -4307,7 +4307,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "90c98deaa4ad8ffa" + "id": "6e9775b5c7a0deb8" }, { "metadata": {}, @@ -4327,13 +4327,13 @@ " app._send = send\n", " return send" ], - "id": "52ac2b17945fa720" + "id": "b32340d5a1a9ffb0" }, { "metadata": {}, "cell_type": "markdown", "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", - "id": "89bbe46ad24bf334" + "id": "f98239f5b55985f5" }, { "metadata": {}, @@ -4350,7 +4350,7 @@ " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" ], - "id": "cd1e16ba0668eba" + "id": "60f073381b0ed2a4" }, { "metadata": {}, @@ -4368,7 +4368,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "3a82c1052cdf37e2" + "id": "f4a88f8993098f8e" }, { "metadata": {}, @@ -4388,7 +4388,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "ca7cbd53eb13747e" + "id": "4d6451e76b4815e9" }, { "metadata": {}, @@ -4404,7 +4404,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "e205251ebab7ba0c" + "id": "6a5123bf9d09da35" }, { "metadata": {}, @@ -4427,7 +4427,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "287ea611bc84670f" + "id": "5d2aef8d29af345a" }, { "metadata": {}, @@ -4438,13 +4438,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "c5d8aacb66ceddeb" + "id": "9d71430122432915" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "dbdbcf414f16a199" + "id": "79b409010c6d95f8" }, { "metadata": {}, @@ -4456,7 +4456,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "962d5c44e34841e" + "id": "407791df301b3b50" } ], "metadata": { From c8ba84959a4f78a0500fc6961613861be07d0974 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 16:43:08 -0300 Subject: [PATCH 16/22] refactor: update code cell metadata in 00_core.ipynb --- nbs/api/00_core.ipynb | 578 +++++++++++++++++++++--------------------- 1 file changed, 289 insertions(+), 289 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 1a055895..f7636b32 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "6b087a4c2f3b6d9" + "id": "c7e41f02d13f9d61" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "f7667c70b7d81829" + "id": "642862afe9ca3012" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "9213151fd0f7268a" + "id": "8a02fcc26408a4c4" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "9c390fef75f5b02b" + "id": "b6daa6ad82d1cf83" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "d1c03a9eead53dfc" + "id": "af008e6e99ff7e68" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "9dba06f8752c292" + "id": "65538566405b08d3" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "162e763547af118a" + "id": "89fd97e7a1142d91" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "d5b03052784eb7e9" + "id": "f2b3eeb2f71383d2" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "9464aced18c6348e" + "id": "6f623389d007e1db" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "e12c5103529c1a4c" + "id": "c5a278e78c12cc62" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "7f348157e8cc2eec" + "id": "4845eba93eb8bca9" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "248c7b4c118d787a" + "id": "8cd29843e34d8803" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "94ae65a29ffd1b95" + "id": "d6188051922bc0e7" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "5219a5d0fcd51fcb" + "id": "b424f050021461de" }, { "metadata": {}, @@ -240,7 +240,7 @@ " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "ace54d35fea9e313" + "id": "63507dcc4af52b52" }, { "metadata": {}, @@ -255,7 +255,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "ca611168acf94812" + "id": "ecca7a9bd8820798" }, { "metadata": {}, @@ -266,7 +266,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "992e5cad97c5c3cd" + "id": "d3fa03557cb648f9" }, { "metadata": {}, @@ -277,13 +277,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "deaa2bfa6d7f91a9" + "id": "7a7fac0dbe9ace0" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "c7b828289addaed7" + "id": "5fb0fdfa34990bec" }, { "metadata": {}, @@ -294,7 +294,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "162370aa5851cefd" + "id": "d063515dfd682c2a" }, { "metadata": {}, @@ -310,7 +310,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "6579d47c7653d929" + "id": "c57103154e94ee0" }, { "metadata": {}, @@ -331,7 +331,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "4be94e3e0b89994d" + "id": "1167e0e777deee48" }, { "metadata": {}, @@ -352,7 +352,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "857a9ec7b5844ecb" + "id": "da0ec5fc70cf9da7" }, { "metadata": {}, @@ -370,7 +370,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "4fcf32555ef2dd23" + "id": "ec433222d57612da" }, { "metadata": {}, @@ -383,7 +383,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "91f51da1537218ff" + "id": "e9fb088a561d7811" }, { "metadata": {}, @@ -397,7 +397,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "c0e50b316a0a23c7" + "id": "e01f72e60bce2e43" }, { "metadata": {}, @@ -411,7 +411,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "2010d881ca470c0c" + "id": "73f467307fb2850a" }, { "metadata": {}, @@ -419,7 +419,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "c2fa630f926f98f" + "id": "fd863d765d7f5322" }, { "metadata": {}, @@ -434,7 +434,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "fb895c514dd736d3" + "id": "72b8dc2ead947d71" }, { "metadata": {}, @@ -442,7 +442,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "e4d4a7958ba129af" + "id": "39631ec8c2f8ac0e" }, { "metadata": {}, @@ -456,7 +456,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "a9a6eda0eb6d2e95" + "id": "e49ecda912344f19" }, { "metadata": {}, @@ -468,7 +468,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "ae30d43af1a70dfc" + "id": "45f9665fd5c1e4eb" }, { "metadata": {}, @@ -479,7 +479,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "2a1122eca66b3f99" + "id": "9a3541904f2ba075" }, { "metadata": {}, @@ -490,7 +490,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "30a0914c08301daa" + "id": "fa21e0d88bc0e7df" }, { "metadata": {}, @@ -506,7 +506,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "62e1d62281ef7a5f" + "id": "4fab1ada645a9b80" }, { "metadata": {}, @@ -520,7 +520,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "d5018c181d420d6" + "id": "b0dd4a7adb417c20" }, { "metadata": {}, @@ -534,7 +534,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "aa68ff13406341e1" + "id": "f3734435b5a8aec2" }, { "metadata": {}, @@ -545,7 +545,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "befba0a90394a295" + "id": "ae1a8058c91c0981" }, { "metadata": {}, @@ -572,13 +572,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "8240407c2c069f23" + "id": "c31c0ef2a67fe61c" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "e39fcff53ef7c916" + "id": "f4dbeb0cc8c4f7fa" }, { "metadata": {}, @@ -592,7 +592,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "23296f7391670991" + "id": "a95ff61630c88a60" }, { "metadata": {}, @@ -606,7 +606,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "1cb3267894e8df34" + "id": "7cc6616f177c0541" }, { "metadata": {}, @@ -630,7 +630,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "375f94ed06202e8" + "id": "497b20c94ae69765" }, { "metadata": {}, @@ -647,13 +647,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "b3170b82c360a59a" + "id": "4c05e36913d5a24" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "129f03948158cdeb" + "id": "8156668f8aa2eff1" }, { "metadata": {}, @@ -669,7 +669,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "1b35de78c9b8a2a1" + "id": "19a5beef8fa1931d" }, { "metadata": {}, @@ -690,7 +690,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "c5b410f7e773cde6" + "id": "6a43a5c0639cfe5" }, { "metadata": {}, @@ -707,13 +707,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "1432f9f0fd614451" + "id": "1bbd3669233fccf6" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "8ee85b7abc1340af" + "id": "c1acf3c4b4c653e8" }, { "metadata": {}, @@ -726,13 +726,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "13a9d36e017a4ee4" + "id": "a8c373e451c3a7a3" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "e98066f74286dfc7" + "id": "f7c8306b4f3cf79a" }, { "metadata": {}, @@ -746,7 +746,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "fea0c687c459a8d3" + "id": "a3737cafabfb315d" }, { "metadata": {}, @@ -758,7 +758,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "eb1bd8fabfb95c55" + "id": "ce63490fd48b393e" }, { "metadata": {}, @@ -826,7 +826,7 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "c9f331ae9caa9f09" + "id": "8942c12bd16a27c" }, { "metadata": {}, @@ -837,7 +837,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "37c41d951f82ab7" + "id": "106bf725a4333f0d" }, { "metadata": {}, @@ -850,7 +850,7 @@ " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "14c7bab54ee7455a" + "id": "45f6c9dce421b01e" }, { "metadata": {}, @@ -863,7 +863,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "de508c22e1e259b7" + "id": "27c71b7668d088c6" }, { "metadata": {}, @@ -883,7 +883,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "822004231e7b2a78" + "id": "f16bbab75fcea8af" }, { "metadata": {}, @@ -901,7 +901,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "2104d7d51d7d5be6" + "id": "d1b450054811b21c" }, { "metadata": {}, @@ -919,7 +919,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "14279e29697f5c49" + "id": "eaf9d2e9a150e81a" }, { "metadata": {}, @@ -941,19 +941,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "aaa4d7aea1bca190" + "id": "2782bf6a1cb322dc" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "d40bb428e237d024" + "id": "a4de353ea7a37d96" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "233a2d6f4a051e34" + "id": "c2097f8222281409" }, { "metadata": {}, @@ -971,13 +971,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "cf738f52ab4f9d85" + "id": "74504b87987400ca" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "57ffd3192662a354" + "id": "34febaa74521586e" }, { "metadata": {}, @@ -995,7 +995,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "8c4f65da2f11665c" + "id": "227bc220f55b05da" }, { "metadata": {}, @@ -1013,7 +1013,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "d7c73fda488e2f32" + "id": "d4c2017dd3c4e89" }, { "metadata": {}, @@ -1025,7 +1025,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "48615c51b5269cbb" + "id": "d29f1612544155d2" }, { "metadata": {}, @@ -1039,7 +1039,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "d78574431e97d0a1" + "id": "b18a6b001272df1" }, { "metadata": {}, @@ -1051,7 +1051,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "646b27d23679375b" + "id": "905d8a20b3cb9502" }, { "metadata": {}, @@ -1062,13 +1062,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "cfad1edd6b247c2f" + "id": "3ea382e197444089" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "2d65b63da1b33be8" + "id": "9d86de612b11ed20" }, { "metadata": {}, @@ -1082,7 +1082,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "5b49eaf5bdd26f83" + "id": "25bcea345ffe8cdc" }, { "metadata": {}, @@ -1118,7 +1118,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "7e65187ac312a2ea" + "id": "7e1de7d057e563ec" }, { "metadata": {}, @@ -1134,7 +1134,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "21d8006298a4cf2e" + "id": "5cda39e0a009f72a" }, { "metadata": {}, @@ -1149,7 +1149,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "93a8009e2b7703a3" + "id": "994bb6a7d16e61b8" }, { "metadata": {}, @@ -1162,7 +1162,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "d73a13761300014e" + "id": "f87942db37d52c0e" }, { "metadata": {}, @@ -1182,13 +1182,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "175ea5578da116b0" + "id": "115bd4d0b33a1ab9" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "7e55ed8326a992fe" + "id": "e29870c88e49519f" }, { "metadata": {}, @@ -1201,7 +1201,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "e54063a6bf41b99a" + "id": "9fa0ddd9cde573e5" }, { "metadata": {}, @@ -1215,7 +1215,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "4b7ffc98448e22ee" + "id": "567e8a3b7d1eeeb9" }, { "metadata": {}, @@ -1226,7 +1226,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "2cf3348466d5813a" + "id": "43db13f341410823" }, { "metadata": {}, @@ -1244,7 +1244,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "29a8876ff03063c5" + "id": "4ea354f3d97395c2" }, { "metadata": {}, @@ -1258,7 +1258,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "ff7a9a97c9d7c9" + "id": "a7b4ee838be04d93" }, { "metadata": {}, @@ -1292,7 +1292,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "78e9ee2ca2d17066" + "id": "29581e48941268fa" }, { "metadata": {}, @@ -1303,7 +1303,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "811b7e5e79ad27ca" + "id": "5c704b3c0f48ca23" }, { "metadata": {}, @@ -1322,7 +1322,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "3dca2824752e328a" + "id": "29c04a29e31e1ea5" }, { "metadata": {}, @@ -1335,7 +1335,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "9fe638d89bdfc7b6" + "id": "e71e3fcd5bbe9b7f" }, { "metadata": {}, @@ -1352,7 +1352,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "c114afe116abd19b" + "id": "9f1c4c47b602fbb6" }, { "metadata": {}, @@ -1361,7 +1361,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "e93bd07a3436cd58" + "id": "1d54647d8ff06b6f" }, { "metadata": {}, @@ -1375,7 +1375,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "1d6c5a66b3840fa3" + "id": "a5d17300f2dc01b6" }, { "metadata": {}, @@ -1400,7 +1400,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "c973a0536f0de4bd" + "id": "841f64aaa499b901" }, { "metadata": {}, @@ -1424,7 +1424,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "dcceeb6fcbe5609b" + "id": "d1d5d53b06d7ad2f" }, { "metadata": {}, @@ -1437,7 +1437,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "3d190d387e2848a7" + "id": "db448c18d71464c3" }, { "metadata": {}, @@ -1465,7 +1465,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "79a2137a019d18e1" + "id": "48c70ecff80d4416" }, { "metadata": {}, @@ -1481,7 +1481,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "9d3c4545eb48d34b" + "id": "1a411b43c0f09298" }, { "metadata": {}, @@ -1495,7 +1495,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "b9c898549e6a8742" + "id": "946242aa0c22fc70" }, { "metadata": {}, @@ -1523,13 +1523,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "13c285a049a19b07" + "id": "a2312e70aacd1632" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "378e7313411a86ab" + "id": "3389fb24163e2bdb" }, { "metadata": {}, @@ -1537,7 +1537,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "2507df810418d1be" + "id": "8961bb0702eac7e6" }, { "metadata": {}, @@ -1554,7 +1554,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "64f094449284adc7" + "id": "8b6cbec7521fbf0" }, { "metadata": {}, @@ -1572,7 +1572,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "a83c8bb972021a02" + "id": "b6e892157fdb54f0" }, { "metadata": {}, @@ -1585,7 +1585,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "35b621c392cc9f1c" + "id": "89afd8f74e33f3da" }, { "metadata": {}, @@ -1603,7 +1603,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "dcb6cb371bb29c26" + "id": "d9714cd6ba28a75b" }, { "metadata": {}, @@ -1623,13 +1623,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "a926802b3069b4c3" + "id": "715d351c67ee9488" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "cdebefbfd6b10b33" + "id": "5bc0428c240cfed" }, { "metadata": {}, @@ -1637,7 +1637,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "b8d362c3a74677b8" + "id": "89dec537f991057" }, { "metadata": {}, @@ -1648,13 +1648,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "5087bf5dd17f278e" + "id": "13ba9b7531d640e4" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "1b41b3e7202c18da" + "id": "f24340c6b6c1296e" }, { "metadata": {}, @@ -1666,7 +1666,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "5f980bcbb4b4b0ad" + "id": "d5bac4cff498d850" }, { "metadata": {}, @@ -1687,7 +1687,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "8832bd1c92a8fc8a" + "id": "693dfac8e0c16bb8" }, { "metadata": {}, @@ -1720,7 +1720,7 @@ " };\n", "\"\"\"))\n" ], - "id": "cc9b30de224c5c3" + "id": "d6474cd3b86f7b9a" }, { "metadata": {}, @@ -1755,7 +1755,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "382c793b6b152466" + "id": "5ea7bb1dfd1b032b" }, { "metadata": {}, @@ -1802,13 +1802,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "966dd501e1982d59" + "id": "e85c6fddbe2e3d74" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "c3b475687fb43a88" + "id": "808f207afbe32336" }, { "metadata": {}, @@ -1834,7 +1834,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "3280dfb123c18a90" + "id": "16c1fee27c8e881b" }, { "metadata": {}, @@ -1854,7 +1854,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "4ca38fd401dc9d9c" + "id": "4c30717ec261c08e" }, { "metadata": {}, @@ -1870,7 +1870,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "5f0d5a0c7650934c" + "id": "4c52ff66356c3ce8" }, { "metadata": {}, @@ -1889,7 +1889,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "4f72e30a87e158c4" + "id": "23e4e79002f2bce6" }, { "metadata": {}, @@ -1900,7 +1900,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "b8c6bf06ebd5e608" + "id": "d073255e9d053b2d" }, { "metadata": {}, @@ -1937,7 +1937,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "282b1ea137d75fb7" + "id": "2f000e1292adf4c1" }, { "metadata": {}, @@ -1955,7 +1955,7 @@ " self.add_route(route)\n", " return func" ], - "id": "257f07d08a7a4c59" + "id": "5115090c73495d6e" }, { "metadata": {}, @@ -1970,7 +1970,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "61b58f14808f749e" + "id": "8709d04b158bd7c0" }, { "metadata": {}, @@ -1984,7 +1984,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "f85125edeb9aed1a" + "id": "a8dfb516a4ef0ad9" }, { "metadata": {}, @@ -2006,7 +2006,7 @@ "\n", " return _lf()" ], - "id": "f50faaeb6f73b5de" + "id": "ce7ac7923b4474e0" }, { "metadata": {}, @@ -2019,7 +2019,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "a390c87b1a3aec49" + "id": "224c6dfa0bec601b" }, { "metadata": {}, @@ -2031,7 +2031,7 @@ " def g(): ...\n", " return g" ], - "id": "36452e19bd9117c8" + "id": "1d03b826ede9c794" }, { "metadata": {}, @@ -2042,7 +2042,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "f056d9ffe477b31c" + "id": "3b392d5976820413" }, { "metadata": {}, @@ -2062,7 +2062,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "c45c1c68184bc4a1" + "id": "4fad4e5fb258843b" }, { "metadata": {}, @@ -2095,7 +2095,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "f5adf4c3b9a7aad5" + "id": "4eaab1c996e39a48" }, { "metadata": {}, @@ -2113,7 +2113,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "2374467745be1179" + "id": "6c60298d227b9f87" }, { "metadata": {}, @@ -2127,7 +2127,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "e35e13178b84f7cc" + "id": "d11da77975f477ea" }, { "metadata": {}, @@ -2140,13 +2140,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "84afe4dac3bf8aad" + "id": "fb321972235a319e" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "657dc2babbae2fb5" + "id": "a07b3bbcca92fb46" }, { "metadata": {}, @@ -2161,7 +2161,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "ea4b2542501d9ced" + "id": "fbee6a0dc379219" }, { "metadata": {}, @@ -2195,13 +2195,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "b43f8e1941124912" + "id": "1c4adaec4f5e6497" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "1f2e847ef0a4e15f" + "id": "4420ce45c317cf4a" }, { "metadata": {}, @@ -2216,13 +2216,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "a3560e79d446ebbd" + "id": "d9e166b722a790ed" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "b40c6ad1eb96881e" + "id": "f5800532ed0fcd00" }, { "metadata": {}, @@ -2245,13 +2245,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "78d477b49a363620" + "id": "5a7e41adc31c18cb" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "8ad28f83ca13f972" + "id": "1546d039be5116d5" }, { "metadata": {}, @@ -2273,7 +2273,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "ac941cbdf42263d6" + "id": "9395ba4338d7ff74" }, { "metadata": {}, @@ -2294,7 +2294,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "a2b670c85a0f928d" + "id": "ecd2cc7e9c5ad0cf" }, { "metadata": {}, @@ -2307,19 +2307,19 @@ "\n", "cli.get('/').text" ], - "id": "a844cc6f1446b208" + "id": "d5fe5b083394e150" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "9fb31f83e54b0af9" + "id": "b6c933a41fa6b910" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "24cebc9cb006db4c" + "id": "76233611db49dc00" }, { "metadata": {}, @@ -2327,7 +2327,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "ca0a971b999f71f1" + "id": "d22f105417c2c01d" }, { "metadata": {}, @@ -2335,7 +2335,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "8866284cfe216dbe" + "id": "bf8fca3366efc22f" }, { "metadata": {}, @@ -2354,7 +2354,7 @@ "\n", "foo.to(param='value')" ], - "id": "c63c83b1e684dbf7" + "id": "ebc3af6ec23fd542" }, { "metadata": {}, @@ -2370,7 +2370,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "ec586e255abbc6af" + "id": "b39169f9cd9bc2f8" }, { "metadata": {}, @@ -2383,7 +2383,7 @@ "\n", "foo.to(param='value')" ], - "id": "fd173cb012d52ece" + "id": "c36bb973535203d9" }, { "metadata": {}, @@ -2399,7 +2399,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "e0adfa68c66ef60c" + "id": "5970130150747a86" }, { "metadata": {}, @@ -2411,7 +2411,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "eb993344e8db44fb" + "id": "a7cf7a576673d95a" }, { "metadata": {}, @@ -2433,7 +2433,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "4f64195cb491d9a" + "id": "2ac41910c4bf14a4" }, { "metadata": {}, @@ -2448,7 +2448,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "3a3e5d71137fef00" + "id": "537123019a9f3ab8" }, { "metadata": {}, @@ -2462,7 +2462,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "28c418860e99d889" + "id": "38ca3e0dd9abb4c7" }, { "metadata": {}, @@ -2475,7 +2475,7 @@ "\n", "cli.post('/hi').text" ], - "id": "393e1dfe598c2000" + "id": "10abe7aed1d9087" }, { "metadata": {}, @@ -2488,7 +2488,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "78f18ac1e1fb5247" + "id": "85fe9c57b38a3c5b" }, { "metadata": {}, @@ -2511,7 +2511,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "4d4bb52a2b1a3c3c" + "id": "93b36f3c165a9a0" }, { "metadata": {}, @@ -2524,7 +2524,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "6bb2986d68edeb2a" + "id": "e6952fc52bbd3df" }, { "metadata": {}, @@ -2536,7 +2536,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "c48df52d1ee142a2" + "id": "6e6ae6afba5edb9b" }, { "metadata": {}, @@ -2548,7 +2548,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "2be1f568f78c1cf5" + "id": "8683c2a58eb44875" }, { "metadata": {}, @@ -2560,7 +2560,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "79cc4ac388e7316e" + "id": "450a74fd2a57be1d" }, { "metadata": {}, @@ -2572,7 +2572,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "38bd2b5ebd6c27c6" + "id": "9b1d7847cc216aeb" }, { "metadata": {}, @@ -2584,7 +2584,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "103a5c8ab391984a" + "id": "dd5b86638148748c" }, { "metadata": {}, @@ -2597,7 +2597,7 @@ "\n", "cli.get('/link').text" ], - "id": "52b2a8491cfa46d9" + "id": "e3d8e0c524267200" }, { "metadata": {}, @@ -2614,7 +2614,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "cf0cb70cf404894e" + "id": "965bf11ea523d815" }, { "metadata": {}, @@ -2622,7 +2622,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "3bbe7501a91bde53" + "id": "96830d1382863f7" }, { "metadata": {}, @@ -2652,7 +2652,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "1c66d8ab2e4c648e" + "id": "42ae0ec543eb889a" }, { "metadata": {}, @@ -2666,7 +2666,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "499f68654b6c02f4" + "id": "8735892e65fa4dac" }, { "metadata": {}, @@ -2678,7 +2678,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "2f7f848752d3511f" + "id": "ad6d485116daff23" }, { "metadata": {}, @@ -2689,7 +2689,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "39a9706c030f062" + "id": "b239bec977272f22" }, { "metadata": {}, @@ -2700,7 +2700,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "975b42c39630221f" + "id": "23dcde1f24d540e8" }, { "metadata": {}, @@ -2726,7 +2726,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "c1933296ec534a96" + "id": "ac9d6f976176cace" }, { "metadata": {}, @@ -2747,7 +2747,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "42ee25f3094d5f56" + "id": "4a3227cd8c38816f" }, { "metadata": {}, @@ -2764,7 +2764,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "6706a5a4586b02df" + "id": "3b5286cf648f699b" }, { "metadata": {}, @@ -2772,7 +2772,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "db66baccd052ca38" + "id": "debe4e5303996beb" }, { "metadata": {}, @@ -2780,7 +2780,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "6c58923204adf869" + "id": "49ac7de43ebe3c57" }, { "metadata": {}, @@ -2818,7 +2818,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "20bcff436b5de63f" + "id": "775b8d9740d334fe" }, { "metadata": {}, @@ -2840,7 +2840,7 @@ " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" ], - "id": "933deee287c91160" + "id": "108d32faefb1a6d2" }, { "metadata": {}, @@ -2852,7 +2852,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "672e209a43efc9b3" + "id": "2a6b2fdf20e88b8f" }, { "metadata": {}, @@ -2863,7 +2863,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "87ffd1d735c4a1ee" + "id": "2c203425ce24ca42" }, { "metadata": {}, @@ -2874,7 +2874,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "a195bd32bcdf8ea5" + "id": "8b033491dd8e1f8b" }, { "metadata": {}, @@ -2885,7 +2885,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "8068f1fc5e8881df" + "id": "296529e338b70512" }, { "metadata": {}, @@ -2900,7 +2900,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "d9de5b5313d8e13b" + "id": "22c2933ef58a13b7" }, { "metadata": {}, @@ -3028,7 +3028,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "4794698bf5a86106" + "id": "c95c9b2725f812c" }, { "metadata": {}, @@ -3036,7 +3036,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "c567b8dfe94a859e" + "id": "9f1062ca667d55ac" }, { "metadata": {}, @@ -3051,7 +3051,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "46fae77110bad117" + "id": "d8382518ab22e380" }, { "metadata": {}, @@ -3063,7 +3063,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "8c708286d4dfc336" + "id": "224bb711ceb17e33" }, { "metadata": {}, @@ -3085,7 +3085,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "791daea606af38be" + "id": "c7830d14c7f1d876" }, { "metadata": {}, @@ -3105,7 +3105,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "21f9df3fdc6ce225" + "id": "f5f7d7022a04e6f2" }, { "metadata": {}, @@ -3118,7 +3118,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "9e16a3ee272d4865" + "id": "e4da9610fb117ee3" }, { "metadata": {}, @@ -3131,7 +3131,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "e136f2047b968402" + "id": "4d74166bbb762323" }, { "metadata": {}, @@ -3142,7 +3142,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "c0e27a587d5d685b" + "id": "d4428f4af5f67ce8" }, { "metadata": {}, @@ -3150,7 +3150,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "f2564de290e329c" + "id": "90fd8966d3dd0dd8" }, { "metadata": {}, @@ -3166,7 +3166,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "42a31940160cac97" + "id": "51b0eebf106c8905" }, { "metadata": {}, @@ -3182,7 +3182,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "266a8d862a4cfde9" + "id": "6f8a06ba6870021f" }, { "metadata": {}, @@ -3199,7 +3199,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "a683d712db56188a" + "id": "c7ebeaff94af317e" }, { "metadata": {}, @@ -3216,13 +3216,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "8e27ab3b6d63a36a" + "id": "1ebaff5d97bf768b" }, { "metadata": {}, "cell_type": "markdown", "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", - "id": "f6e5316b2603036c" + "id": "ae13a2534afe29a2" }, { "metadata": {}, @@ -3230,7 +3230,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "452ce94aed456532" + "id": "f09dea1b7cc7765d" }, { "metadata": {}, @@ -3259,13 +3259,13 @@ "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" ], - "id": "6db3d836c31699b6" + "id": "65e5e4d819b9e20c" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "8df5c1eff76d21f7" + "id": "408406de6a274cf9" }, { "metadata": {}, @@ -3273,7 +3273,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "513c5251ca32ab98" + "id": "3bb8881330355d27" }, { "metadata": {}, @@ -3290,7 +3290,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "12bb98ee628f5c29" + "id": "d642c1f1d15bb4c" }, { "metadata": {}, @@ -3308,13 +3308,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "ceddb20f4f628803" + "id": "778d773193ca4ff8" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "fcc30d146e8b33d4" + "id": "821c39b743c32cb8" }, { "metadata": {}, @@ -3336,13 +3336,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "3719ca72ca32854f" + "id": "37432ea78ba02b9d" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "d5bf6a93b3583775" + "id": "3dfebc3f359634ad" }, { "metadata": {}, @@ -3358,13 +3358,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "7588d17481dd8440" + "id": "bee2b8c7a8fd728a" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "874cf832d6cf48c5" + "id": "f558641587e4bab0" }, { "metadata": {}, @@ -3384,13 +3384,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "b87d5db7952eb2b1" + "id": "e9a5239078afee2" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "5f5e0830b720dce0" + "id": "c760bae67e2139dd" }, { "metadata": {}, @@ -3406,7 +3406,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "9ae5f58d1d44af2a" + "id": "1dd96d9ef0acc2a1" }, { "metadata": {}, @@ -3422,13 +3422,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "692a70fe9d83ae1d" + "id": "5e6783f1443a8fc9" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "44245a632212c6aa" + "id": "c0e2a8564dc5f298" }, { "metadata": {}, @@ -3453,13 +3453,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "708c14c7c1d8204b" + "id": "5a8c9d2d917519d7" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "ab850e2d804c43f" + "id": "7d4e1498760dd859" }, { "metadata": {}, @@ -3478,7 +3478,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "fbefbbdc5fac6a43" + "id": "cd31207457efd4a6" }, { "metadata": {}, @@ -3526,7 +3526,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "1219db3b16b794a" + "id": "e024bfae1effc7f8" }, { "metadata": {}, @@ -3534,7 +3534,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "2a3357acbbb20fb1" + "id": "8503fae77f6b634b" }, { "metadata": {}, @@ -3564,7 +3564,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "5fe7fd73f9c1d5f8" + "id": "458cb8e0210a638c" }, { "metadata": {}, @@ -3575,7 +3575,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "393adba61d24eda5" + "id": "891b17a0715b1f3e" }, { "metadata": {}, @@ -3593,7 +3593,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "fd226f94e283e666" + "id": "e938cdbd7ffd2683" }, { "metadata": {}, @@ -3614,7 +3614,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "519cb8c2d0b204bf" + "id": "4048ab20b567acf" }, { "metadata": {}, @@ -3627,7 +3627,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "2f3234ab80266cc1" + "id": "cb560378ae3b5bd0" }, { "metadata": {}, @@ -3635,7 +3635,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "a08ee86a54f77995" + "id": "4470384a3b80253f" }, { "metadata": {}, @@ -3663,7 +3663,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "95a34335abeff730" + "id": "323357c9713b9904" }, { "metadata": {}, @@ -3674,7 +3674,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "df4539565827cd9" + "id": "e33e529f57d383d3" }, { "metadata": {}, @@ -3691,7 +3691,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "bab08c9701bd9b3e" + "id": "7209fa015b93ec8e" }, { "metadata": {}, @@ -3710,7 +3710,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "8ccbea2f28bac1be" + "id": "c6efccb37def7269" }, { "metadata": {}, @@ -3723,7 +3723,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "4ca461d0bc3b874e" + "id": "f4cc54ca0a001665" }, { "metadata": {}, @@ -3734,7 +3734,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "792b54853bf957db" + "id": "1135e59c6ef81829" }, { "metadata": {}, @@ -3756,13 +3756,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "af400a3ee4144c60" + "id": "c0557abd43cd598b" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "bc37f41babbbcbb6" + "id": "3ddb613fb2e14125" }, { "metadata": {}, @@ -3770,7 +3770,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "bf336e455698c7f2" + "id": "67aae9f06b7be50a" }, { "metadata": {}, @@ -3795,7 +3795,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "b6468658a9949251" + "id": "165efefd99a8d157" }, { "metadata": {}, @@ -3813,7 +3813,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "bcef5541adc3b76" + "id": "76d9a9b30ba024a1" }, { "metadata": {}, @@ -3827,7 +3827,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "3eae31c3ef7ae39a" + "id": "361ac1ea84f244db" }, { "metadata": {}, @@ -3847,7 +3847,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "9333a8aa3131fa87" + "id": "188cbe278772a153" }, { "metadata": {}, @@ -3862,7 +3862,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "15c876f8ea8489f6" + "id": "2d5d9474e3affcbc" }, { "metadata": {}, @@ -3873,7 +3873,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "2d0b1f454422b863" + "id": "131cb1705b1a3983" }, { "metadata": {}, @@ -3888,7 +3888,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "d8e388d5d93c353f" + "id": "82d00e836bb451fd" }, { "metadata": {}, @@ -3899,7 +3899,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "41723eb1c447498d" + "id": "43c779f084baa19b" }, { "metadata": {}, @@ -3915,7 +3915,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "f8b812a4bee5f891" + "id": "bd60c9f4046e839f" }, { "metadata": {}, @@ -3931,13 +3931,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "f327ec92e995ed39" + "id": "f5fbf53748deb383" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "d3dd20bae3fdbcad" + "id": "66fc62210e475fa4" }, { "metadata": {}, @@ -3952,7 +3952,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "9511541bb4b118c" + "id": "a571da6de24bc873" }, { "metadata": {}, @@ -3971,13 +3971,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "573837cc71fe6744" + "id": "735144d2837c118" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "9f148c9306ee68ba" + "id": "9bd8ff9f101bf1a" }, { "metadata": {}, @@ -3997,7 +3997,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "78e1b9d2adef8930" + "id": "123a17ac6bceedfa" }, { "metadata": {}, @@ -4009,7 +4009,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "df646f0b1a35d2d4" + "id": "906f0140ec0c7916" }, { "metadata": {}, @@ -4028,7 +4028,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "5bfeb75f765f7f80" + "id": "73bd66558e445a9f" }, { "metadata": {}, @@ -4058,19 +4058,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "c41bf6d621614ca8" + "id": "63813016a6b7f21d" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "a5714587cedabe" + "id": "a9d329021dcc9795" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "d4bcc99873df7a1b" + "id": "f4670220611a15d5" }, { "metadata": {}, @@ -4086,13 +4086,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "d030291042cf0a9" + "id": "e390e30eabaa7dff" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "ea80c7fd37667f14" + "id": "1c9b67665004aee2" }, { "metadata": {}, @@ -4104,13 +4104,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "e44f15a4e0b8b6d5" + "id": "2bd1cae87d69b116" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "8b3d12e0c319e7c6" + "id": "b98cf89f4891635a" }, { "metadata": {}, @@ -4122,7 +4122,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "38376fa297413604" + "id": "ba212aeee60afe52" }, { "metadata": {}, @@ -4138,7 +4138,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "2ad0002dd43d7df5" + "id": "1096e43481864dfd" }, { "metadata": {}, @@ -4161,7 +4161,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "8b985c121ddebe48" + "id": "36273444869b2cc7" }, { "metadata": {}, @@ -4181,13 +4181,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "510d518764e4313b" + "id": "2a58891859236be6" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "6d57edf2b50510af" + "id": "281125b7dac64fb" }, { "metadata": {}, @@ -4208,13 +4208,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "6e2a806f070b7933" + "id": "153e7c1b259d4afa" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "23d87040990382d" + "id": "b0c1219aa2b25514" }, { "metadata": {}, @@ -4228,7 +4228,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "d481195695540e06" + "id": "8c9e6dfa03f52b5b" }, { "metadata": {}, @@ -4245,7 +4245,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "c76db0d654c4399b" + "id": "c44564dc6d81b45f" }, { "metadata": {}, @@ -4261,7 +4261,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "80a1cb212c505d99" + "id": "1a04c4d0ba03cfca" }, { "metadata": {}, @@ -4278,7 +4278,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "bf5640016cdb04dc" + "id": "75841e9b6f563b3e" }, { "metadata": {}, @@ -4293,7 +4293,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "aefa7050c3afef64" + "id": "4a0a018ee7e76f63" }, { "metadata": {}, @@ -4307,7 +4307,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "6e9775b5c7a0deb8" + "id": "4253d155b80c4556" }, { "metadata": {}, @@ -4327,13 +4327,13 @@ " app._send = send\n", " return send" ], - "id": "b32340d5a1a9ffb0" + "id": "c1f7865ab79a4ff1" }, { "metadata": {}, "cell_type": "markdown", "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", - "id": "f98239f5b55985f5" + "id": "831365f12b3d6749" }, { "metadata": {}, @@ -4350,7 +4350,7 @@ " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" ], - "id": "60f073381b0ed2a4" + "id": "c756dbb3979f83bd" }, { "metadata": {}, @@ -4368,7 +4368,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "f4a88f8993098f8e" + "id": "a25f4a9f7e1baed5" }, { "metadata": {}, @@ -4388,7 +4388,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "4d6451e76b4815e9" + "id": "36b8ea0df4d3f632" }, { "metadata": {}, @@ -4404,7 +4404,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "6a5123bf9d09da35" + "id": "6f633e5e9e8b23ab" }, { "metadata": {}, @@ -4427,7 +4427,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "5d2aef8d29af345a" + "id": "89568352973aa146" }, { "metadata": {}, @@ -4438,13 +4438,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "9d71430122432915" + "id": "3ccded46057fcce9" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "79b409010c6d95f8" + "id": "2b322ba467728be8" }, { "metadata": {}, @@ -4456,7 +4456,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "407791df301b3b50" + "id": "65740f4518856bff" } ], "metadata": { From 2e15217cb86e53e7fd55402995a41cddcc97ae12 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 20 Oct 2026 17:06:04 -0300 Subject: [PATCH 17/22] refactor: update code cell structure --- nbs/api/00_core.ipynb | 578 +++++++++++++++++++++--------------------- 1 file changed, 289 insertions(+), 289 deletions(-) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index f7636b32..62cf20da 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -6,7 +6,7 @@ "outputs": [], "execution_count": null, "source": "#| default_exp core", - "id": "c7e41f02d13f9d61" + "id": "62225a49ec1f2684" }, { "metadata": {}, @@ -15,7 +15,7 @@ "# Core\n", "> The `FastHTML` subclass of `Starlette`." ], - "id": "642862afe9ca3012" + "id": "1edf4b13531905e7" }, { "metadata": {}, @@ -62,19 +62,19 @@ "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." ], - "id": "8a02fcc26408a4c4" + "id": "20a5d1d37b1c0534" }, { "metadata": {}, "cell_type": "markdown", "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "b6daa6ad82d1cf83" + "id": "b69e500bd089eb18" }, { "metadata": {}, "cell_type": "markdown", "source": "## Imports and utils", - "id": "af008e6e99ff7e68" + "id": "841096cebec26ecd" }, { "metadata": {}, @@ -110,7 +110,7 @@ "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" ], - "id": "65538566405b08d3" + "id": "766f7b53b1b8fad9" }, { "metadata": {}, @@ -131,7 +131,7 @@ "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" ], - "id": "89fd97e7a1142d91" + "id": "b17d8daec9d4f0ea" }, { "metadata": {}, @@ -144,13 +144,13 @@ "\n", "empty = Parameter.empty" ], - "id": "f2b3eeb2f71383d2" + "id": "e63824c9a9c99d2a" }, { "metadata": {}, "cell_type": "markdown", "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "6f623389d007e1db" + "id": "e638f0fba61e9334" }, { "metadata": {}, @@ -163,7 +163,7 @@ " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" ], - "id": "c5a278e78c12cc62" + "id": "154cd2aacf343e17" }, { "metadata": {}, @@ -171,7 +171,7 @@ "outputs": [], "execution_count": null, "source": "parsed_date('2pm')", - "id": "4845eba93eb8bca9" + "id": "2758feb3c71a15cc" }, { "metadata": {}, @@ -179,7 +179,7 @@ "outputs": [], "execution_count": null, "source": "isinstance(date.fromtimestamp(0), date)", - "id": "8cd29843e34d8803" + "id": "5779523e889512af" }, { "metadata": {}, @@ -193,7 +193,7 @@ " s = snake2camel(s)\n", " return camel2words(s, '-')" ], - "id": "d6188051922bc0e7" + "id": "114894324f4f3de6" }, { "metadata": {}, @@ -201,7 +201,7 @@ "outputs": [], "execution_count": null, "source": "snake2hyphens(\"snake_case\")", - "id": "b424f050021461de" + "id": "34e26a3028f8b6f3" }, { "metadata": {}, @@ -240,7 +240,7 @@ " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" ], - "id": "63507dcc4af52b52" + "id": "b5cf37e1b1e83204" }, { "metadata": {}, @@ -255,7 +255,7 @@ " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" ], - "id": "ecca7a9bd8820798" + "id": "7cbc3bc299e0f7d6" }, { "metadata": {}, @@ -266,7 +266,7 @@ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" ], - "id": "d3fa03557cb648f9" + "id": "c04a34a18145b957" }, { "metadata": {}, @@ -277,13 +277,13 @@ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" ], - "id": "7a7fac0dbe9ace0" + "id": "66c896da0bfefa83" }, { "metadata": {}, "cell_type": "markdown", "source": "## Request and response", - "id": "5fb0fdfa34990bec" + "id": "4e0d5c759fcbd3c2" }, { "metadata": {}, @@ -294,7 +294,7 @@ "#| export\n", "fh_cfg = AttrDict(indent=True)" ], - "id": "d063515dfd682c2a" + "id": "e174c8645b0457a3" }, { "metadata": {}, @@ -310,7 +310,7 @@ " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" ], - "id": "c57103154e94ee0" + "id": "ce5bb0bd514a1fc6" }, { "metadata": {}, @@ -331,7 +331,7 @@ " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" ], - "id": "1167e0e777deee48" + "id": "f359709b8822b93c" }, { "metadata": {}, @@ -352,7 +352,7 @@ "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" ], - "id": "da0ec5fc70cf9da7" + "id": "1f8f2a43982d41fe" }, { "metadata": {}, @@ -370,7 +370,7 @@ " if not anno: return v\n", " return _fix_anno(anno, v)" ], - "id": "ec433222d57612da" + "id": "2a3c54c664b362a8" }, { "metadata": {}, @@ -383,7 +383,7 @@ "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" ], - "id": "e9fb088a561d7811" + "id": "167225e773be8206" }, { "metadata": {}, @@ -397,7 +397,7 @@ " \"A response header as a k,v pair\"\n", " k:str;v:str" ], - "id": "e01f72e60bce2e43" + "id": "15144ae89669849e" }, { "metadata": {}, @@ -411,7 +411,7 @@ "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" ], - "id": "73f467307fb2850a" + "id": "84a3dc1748f845d8" }, { "metadata": {}, @@ -419,7 +419,7 @@ "outputs": [], "execution_count": null, "source": "_to_htmx_header('trigger_after_settle')", - "id": "fd863d765d7f5322" + "id": "cf91ff06890e82bd" }, { "metadata": {}, @@ -434,7 +434,7 @@ " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" ], - "id": "72b8dc2ead947d71" + "id": "63cbc11b77a7373f" }, { "metadata": {}, @@ -442,7 +442,7 @@ "outputs": [], "execution_count": null, "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "39631ec8c2f8ac0e" + "id": "45e23fb992603d24" }, { "metadata": {}, @@ -456,7 +456,7 @@ " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" ], - "id": "e49ecda912344f19" + "id": "4bc38ff0755a2c43" }, { "metadata": {}, @@ -468,7 +468,7 @@ "class Foo: a:int; b:str\n", "_annotations(Foo)" ], - "id": "45f9665fd5c1e4eb" + "id": "8818f6785ea4b2d7" }, { "metadata": {}, @@ -479,7 +479,7 @@ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" ], - "id": "9a3541904f2ba075" + "id": "d4bcd3236020690" }, { "metadata": {}, @@ -490,7 +490,7 @@ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" ], - "id": "fa21e0d88bc0e7df" + "id": "27c857509c0182ab" }, { "metadata": {}, @@ -506,7 +506,7 @@ " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" ], - "id": "4fab1ada645a9b80" + "id": "36117c4b0c779d27" }, { "metadata": {}, @@ -520,7 +520,7 @@ " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" ], - "id": "b0dd4a7adb417c20" + "id": "8b473215908c026d" }, { "metadata": {}, @@ -534,7 +534,7 @@ "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" ], - "id": "f3734435b5a8aec2" + "id": "9e0a6dc2bbb2072e" }, { "metadata": {}, @@ -545,7 +545,7 @@ "#| export\n", "DEF_MAXPART = 100*1024*1024" ], - "id": "ae1a8058c91c0981" + "id": "b1ddddcde3b83c93" }, { "metadata": {}, @@ -572,13 +572,13 @@ " return res\n", " return await req.form(max_part_size=maxpart)" ], - "id": "c31c0ef2a67fe61c" + "id": "973ffa45a4d79eee" }, { "metadata": {}, "cell_type": "markdown", "source": "Use the special `__from_request__` class method for custom creation:", - "id": "f4dbeb0cc8c4f7fa" + "id": "4e6df306be40bbc2" }, { "metadata": {}, @@ -592,7 +592,7 @@ " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" ], - "id": "a95ff61630c88a60" + "id": "ed2adb6bdeea43c6" }, { "metadata": {}, @@ -606,7 +606,7 @@ "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" ], - "id": "7cc6616f177c0541" + "id": "c9dd716c48ee9b37" }, { "metadata": {}, @@ -630,7 +630,7 @@ " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" ], - "id": "497b20c94ae69765" + "id": "caeeb8021b015e6a" }, { "metadata": {}, @@ -647,13 +647,13 @@ " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" ], - "id": "4c05e36913d5a24" + "id": "71eea63edd703329" }, { "metadata": {}, "cell_type": "markdown", "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "8156668f8aa2eff1" + "id": "a96a235863cfa801" }, { "metadata": {}, @@ -669,7 +669,7 @@ " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" ], - "id": "19a5beef8fa1931d" + "id": "76fd012e7f06d6c4" }, { "metadata": {}, @@ -690,7 +690,7 @@ "response = client.post('/', data=d)\n", "print(response.json())" ], - "id": "6a43a5c0639cfe5" + "id": "8f6b6d744235f447" }, { "metadata": {}, @@ -707,13 +707,13 @@ "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" ], - "id": "1bbd3669233fccf6" + "id": "462118d0a1a4f66b" }, { "metadata": {}, "cell_type": "markdown", "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "c1acf3c4b4c653e8" + "id": "5584c41a615aabde" }, { "metadata": {}, @@ -726,13 +726,13 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "a8c373e451c3a7a3" + "id": "21df476998395009" }, { "metadata": {}, "cell_type": "markdown", "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "f7c8306b4f3cf79a" + "id": "c20f8a892d3edc2a" }, { "metadata": {}, @@ -746,7 +746,7 @@ "test_eq(res.status_code, 400)\n", "res.text" ], - "id": "a3737cafabfb315d" + "id": "f4b885f06ed6448d" }, { "metadata": {}, @@ -758,7 +758,7 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" ], - "id": "ce63490fd48b393e" + "id": "a93058bcf54da24b" }, { "metadata": {}, @@ -826,7 +826,7 @@ " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" ], - "id": "8942c12bd16a27c" + "id": "fe8d6fb3a21bfc08" }, { "metadata": {}, @@ -837,7 +837,7 @@ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" ], - "id": "106bf725a4333f0d" + "id": "651f224eda02bc8f" }, { "metadata": {}, @@ -850,7 +850,7 @@ " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ], - "id": "45f6c9dce421b01e" + "id": "c342b5ba562b350d" }, { "metadata": {}, @@ -863,7 +863,7 @@ " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" ], - "id": "27c71b7668d088c6" + "id": "84bf5afdd0d94dba" }, { "metadata": {}, @@ -883,7 +883,7 @@ "test_eq(r.status_code, 200)\n", "print(r.text)" ], - "id": "f16bbab75fcea8af" + "id": "20396c4110d87e07" }, { "metadata": {}, @@ -901,7 +901,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "d1b450054811b21c" + "id": "e209b6e5ff739895" }, { "metadata": {}, @@ -919,7 +919,7 @@ "response = client.post('/?a=1', data=d)\n", "print(response.text)" ], - "id": "eaf9d2e9a150e81a" + "id": "6a996814b84b8b6a" }, { "metadata": {}, @@ -941,19 +941,19 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" ], - "id": "2782bf6a1cb322dc" + "id": "307157d9853b1ed8" }, { "metadata": {}, "cell_type": "markdown", "source": "**Missing Request Params**", - "id": "a4de353ea7a37d96" + "id": "d81764a19d62bae7" }, { "metadata": {}, "cell_type": "markdown", "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "c2097f8222281409" + "id": "2bb2bb7b11dc989b" }, { "metadata": {}, @@ -971,13 +971,13 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "74504b87987400ca" + "id": "618408bc44ea081e" }, { "metadata": {}, "cell_type": "markdown", "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "34febaa74521586e" + "id": "dd41603d4db6a5e6" }, { "metadata": {}, @@ -995,7 +995,7 @@ "response = client.post('/', json={}) # no param in request\n", "print(response.text)" ], - "id": "227bc220f55b05da" + "id": "4acfa6fdeca0bdcb" }, { "metadata": {}, @@ -1013,7 +1013,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "d4c2017dd3c4e89" + "id": "b9ee2d9aa97f3567" }, { "metadata": {}, @@ -1025,7 +1025,7 @@ "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" ], - "id": "d29f1612544155d2" + "id": "6e6d978b12ecec1a" }, { "metadata": {}, @@ -1039,7 +1039,7 @@ " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" ], - "id": "b18a6b001272df1" + "id": "ddf1e8cede32b6ba" }, { "metadata": {}, @@ -1051,7 +1051,7 @@ "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" ], - "id": "905d8a20b3cb9502" + "id": "cc5c6cdf9602dd50" }, { "metadata": {}, @@ -1062,13 +1062,13 @@ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" ], - "id": "3ea382e197444089" + "id": "7c3c48b78747ac48" }, { "metadata": {}, "cell_type": "markdown", "source": "## Websockets / SSE", - "id": "9d86de612b11ed20" + "id": "84e9432fd3a4ab2b" }, { "metadata": {}, @@ -1082,7 +1082,7 @@ " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" ], - "id": "25bcea345ffe8cdc" + "id": "30af027af06ad5a7" }, { "metadata": {}, @@ -1118,7 +1118,7 @@ " cls.on_receive = _recv\n", " return cls" ], - "id": "7e1de7d057e563ec" + "id": "cbcf66eb4bf6d993" }, { "metadata": {}, @@ -1134,7 +1134,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" ], - "id": "5cda39e0a009f72a" + "id": "47d1b8f409e41924" }, { "metadata": {}, @@ -1149,7 +1149,7 @@ " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" ], - "id": "994bb6a7d16e61b8" + "id": "f655649f2c9b2a90" }, { "metadata": {}, @@ -1162,7 +1162,7 @@ " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" ], - "id": "f87942db37d52c0e" + "id": "94d9b06b3345639e" }, { "metadata": {}, @@ -1182,13 +1182,13 @@ " self._orig_handle_exit(*args, **kwargs)\n", " return event" ], - "id": "115bd4d0b33a1ab9" + "id": "76536fa30a36fd84" }, { "metadata": {}, "cell_type": "markdown", "source": "## Routing and application", - "id": "e29870c88e49519f" + "id": "f3251662d7ccbe86" }, { "metadata": {}, @@ -1201,7 +1201,7 @@ " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" ], - "id": "9fa0ddd9cde573e5" + "id": "1df38e4652418428" }, { "metadata": {}, @@ -1215,7 +1215,7 @@ " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" ], - "id": "567e8a3b7d1eeeb9" + "id": "912efff3d21d23b8" }, { "metadata": {}, @@ -1226,7 +1226,7 @@ "#| export\n", "from starlette.convertors import StringConvertor" ], - "id": "43db13f341410823" + "id": "7cf4345c88d621ac" }, { "metadata": {}, @@ -1244,7 +1244,7 @@ " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" ], - "id": "4ea354f3d97395c2" + "id": "35645793ee2333d9" }, { "metadata": {}, @@ -1258,7 +1258,7 @@ " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" ], - "id": "a7b4ee838be04d93" + "id": "7069302c62fce8e8" }, { "metadata": {}, @@ -1292,7 +1292,7 @@ " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" ], - "id": "29581e48941268fa" + "id": "ebab309b5bd1c979" }, { "metadata": {}, @@ -1303,7 +1303,7 @@ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" ], - "id": "5c704b3c0f48ca23" + "id": "4442bbbe5651ae8e" }, { "metadata": {}, @@ -1322,7 +1322,7 @@ " else: result.append(item)\n", " return tuple(result)" ], - "id": "29c04a29e31e1ea5" + "id": "a13f07df3079ff3" }, { "metadata": {}, @@ -1335,7 +1335,7 @@ " \"Default Body wrap function which just returns the content\"\n", " return c" ], - "id": "e71e3fcd5bbe9b7f" + "id": "8f7f3a3405e1e697" }, { "metadata": {}, @@ -1352,7 +1352,7 @@ " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" ], - "id": "9f1c4c47b602fbb6" + "id": "8266da60e013daa5" }, { "metadata": {}, @@ -1361,7 +1361,7 @@ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" ], - "id": "1d54647d8ff06b6f" + "id": "53b1bc8f6257741d" }, { "metadata": {}, @@ -1375,7 +1375,7 @@ " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" ], - "id": "a5d17300f2dc01b6" + "id": "2c2152165492b8c9" }, { "metadata": {}, @@ -1400,7 +1400,7 @@ " if len(resp)==1: resp = resp[0]\n", " return resp,kw" ], - "id": "841f64aaa499b901" + "id": "f151183b32687f68" }, { "metadata": {}, @@ -1424,7 +1424,7 @@ " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" ], - "id": "d1d5d53b06d7ad2f" + "id": "2ac4e8b2587860d1" }, { "metadata": {}, @@ -1437,7 +1437,7 @@ " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" ], - "id": "db448c18d71464c3" + "id": "e6934cf43a725446" }, { "metadata": {}, @@ -1465,7 +1465,7 @@ " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" ], - "id": "48c70ecff80d4416" + "id": "2f67ee9bffca1af0" }, { "metadata": {}, @@ -1481,7 +1481,7 @@ " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" ], - "id": "1a411b43c0f09298" + "id": "dba9d4e6f6485582" }, { "metadata": {}, @@ -1495,7 +1495,7 @@ " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" ], - "id": "946242aa0c22fc70" + "id": "2c57c9e3b3080bf6" }, { "metadata": {}, @@ -1523,13 +1523,13 @@ " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" ], - "id": "a2312e70aacd1632" + "id": "ce19cf0e812f4eed" }, { "metadata": {}, "cell_type": "markdown", "source": "The FastHTML `exts` param supports the following:", - "id": "3389fb24163e2bdb" + "id": "dd98be8810a6ceee" }, { "metadata": {}, @@ -1537,7 +1537,7 @@ "outputs": [], "execution_count": null, "source": "print(' '.join(htmx_exts))", - "id": "8961bb0702eac7e6" + "id": "f17d31038131da6f" }, { "metadata": {}, @@ -1554,7 +1554,7 @@ "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" ], - "id": "8b6cbec7521fbf0" + "id": "57564d874a5020f6" }, { "metadata": {}, @@ -1572,7 +1572,7 @@ " fname.write_text(key)\n", " return key" ], - "id": "b6e892157fdb54f0" + "id": "734e1a07d2bbcb10" }, { "metadata": {}, @@ -1585,7 +1585,7 @@ " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" ], - "id": "89afd8f74e33f3da" + "id": "53259808c83e5169" }, { "metadata": {}, @@ -1603,7 +1603,7 @@ " return _resp(req, res, status_code=status_code)\n", " return _f" ], - "id": "d9714cd6ba28a75b" + "id": "89a5250eb2a3a719" }, { "metadata": {}, @@ -1623,13 +1623,13 @@ " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" ], - "id": "715d351c67ee9488" + "id": "fc5507023074e955" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` adds query parameters to route path strings", - "id": "5bc0428c240cfed" + "id": "f862919a89f4caa" }, { "metadata": {}, @@ -1637,7 +1637,7 @@ "outputs": [], "execution_count": null, "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "89dec537f991057" + "id": "d3e99daf5c14924e" }, { "metadata": {}, @@ -1648,13 +1648,13 @@ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" ], - "id": "13ba9b7531d640e4" + "id": "b9af73fa45922b9d" }, { "metadata": {}, "cell_type": "markdown", "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "f24340c6b6c1296e" + "id": "d7d70e650e9becb0" }, { "metadata": {}, @@ -1666,7 +1666,7 @@ "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" ], - "id": "d5bac4cff498d850" + "id": "7197456ec5431ffc" }, { "metadata": {}, @@ -1687,7 +1687,7 @@ " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ], - "id": "693dfac8e0c16bb8" + "id": "6d573895895b93de" }, { "metadata": {}, @@ -1720,7 +1720,7 @@ " };\n", "\"\"\"))\n" ], - "id": "d6474cd3b86f7b9a" + "id": "380c8297ab1fdf13" }, { "metadata": {}, @@ -1755,7 +1755,7 @@ "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" ], - "id": "5ea7bb1dfd1b032b" + "id": "9361b5e43a27fd11" }, { "metadata": {}, @@ -1802,13 +1802,13 @@ "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" ], - "id": "e85c6fddbe2e3d74" + "id": "28c9653b6c0a14af" }, { "metadata": {}, "cell_type": "markdown", "source": "### Routes", - "id": "808f207afbe32336" + "id": "865296f3f1f20553" }, { "metadata": {}, @@ -1834,7 +1834,7 @@ " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" ], - "id": "16c1fee27c8e881b" + "id": "4f15c16542b89f8" }, { "metadata": {}, @@ -1854,7 +1854,7 @@ "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" ], - "id": "4c30717ec261c08e" + "id": "efd9140ff39fe5c4" }, { "metadata": {}, @@ -1870,7 +1870,7 @@ "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" ], - "id": "4c52ff66356c3ce8" + "id": "257f7e4981d02717" }, { "metadata": {}, @@ -1889,7 +1889,7 @@ " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ], - "id": "23e4e79002f2bce6" + "id": "caffc08b07c8bcb" }, { "metadata": {}, @@ -1900,7 +1900,7 @@ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" ], - "id": "d073255e9d053b2d" + "id": "e9f4f950ae9801d6" }, { "metadata": {}, @@ -1937,7 +1937,7 @@ " return _resp(req, resp, sig.return_annotation)\n", " return _f" ], - "id": "2f000e1292adf4c1" + "id": "35dc94a31e586f2f" }, { "metadata": {}, @@ -1955,7 +1955,7 @@ " self.add_route(route)\n", " return func" ], - "id": "5115090c73495d6e" + "id": "6f42e9795ed9d244" }, { "metadata": {}, @@ -1970,7 +1970,7 @@ " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" ], - "id": "8709d04b158bd7c0" + "id": "de7fe08284137add" }, { "metadata": {}, @@ -1984,7 +1984,7 @@ " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" ], - "id": "a8dfb516a4ef0ad9" + "id": "87187e749e5e931e" }, { "metadata": {}, @@ -2006,7 +2006,7 @@ "\n", " return _lf()" ], - "id": "ce7ac7923b4474e0" + "id": "33d86a55c9b92f78" }, { "metadata": {}, @@ -2019,7 +2019,7 @@ " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" ], - "id": "224c6dfa0bec601b" + "id": "35c6706df3663277" }, { "metadata": {}, @@ -2031,7 +2031,7 @@ " def g(): ...\n", " return g" ], - "id": "1d03b826ede9c794" + "id": "59602d676eadf6ad" }, { "metadata": {}, @@ -2042,7 +2042,7 @@ "func = f()\n", "nested_name(func)" ], - "id": "3b392d5976820413" + "id": "8d0508427acea4f7" }, { "metadata": {}, @@ -2062,7 +2062,7 @@ " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" ], - "id": "4fad4e5fb258843b" + "id": "5e57c9b359478ec4" }, { "metadata": {}, @@ -2095,7 +2095,7 @@ " lf.__routename__ = n\n", " return lf" ], - "id": "4eaab1c996e39a48" + "id": "958685a59c1adc37" }, { "metadata": {}, @@ -2113,7 +2113,7 @@ "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" ], - "id": "6c60298d227b9f87" + "id": "6d07f10ad2ee837c" }, { "metadata": {}, @@ -2127,7 +2127,7 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "d11da77975f477ea" + "id": "3d2d35aafa07d69e" }, { "metadata": {}, @@ -2140,13 +2140,13 @@ "\n", "foo.to(a='bar', b=[1,2])" ], - "id": "fb321972235a319e" + "id": "1bbd2e215906320a" }, { "metadata": {}, "cell_type": "markdown", "source": "### Utils", - "id": "a07b3bbcca92fb46" + "id": "1c55d966dad7b5ec" }, { "metadata": {}, @@ -2161,7 +2161,7 @@ " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" ], - "id": "fbee6a0dc379219" + "id": "2871dd3926e94cce" }, { "metadata": {}, @@ -2195,13 +2195,13 @@ " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" ], - "id": "1c4adaec4f5e6497" + "id": "f7dd4fc84b21b1d5" }, { "metadata": {}, "cell_type": "markdown", "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "4420ce45c317cf4a" + "id": "f20cb7f71dea4782" }, { "metadata": {}, @@ -2216,13 +2216,13 @@ " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" ], - "id": "d9e166b722a790ed" + "id": "f34f9b5ca6567162" }, { "metadata": {}, "cell_type": "markdown", "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "f5800532ed0fcd00" + "id": "be29597fe5b17234" }, { "metadata": {}, @@ -2245,13 +2245,13 @@ " watch.cancel()\n", " if not work.done(): work.cancel()" ], - "id": "5a7e41adc31c18cb" + "id": "2cb43965c7a83d32" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "1546d039be5116d5" + "id": "395800c38312e62e" }, { "metadata": {}, @@ -2273,7 +2273,7 @@ " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" ], - "id": "9395ba4338d7ff74" + "id": "a5ce96916bc04402" }, { "metadata": {}, @@ -2294,7 +2294,7 @@ "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" ], - "id": "ecd2cc7e9c5ad0cf" + "id": "83d324009fdb6f1e" }, { "metadata": {}, @@ -2307,19 +2307,19 @@ "\n", "cli.get('/').text" ], - "id": "d5fe5b083394e150" + "id": "f7efbbe1ce446de9" }, { "metadata": {}, "cell_type": "markdown", "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "b6c933a41fa6b910" + "id": "285cc520072871ae" }, { "metadata": {}, "cell_type": "markdown", "source": "## FastHTML Tests", - "id": "76233611db49dc00" + "id": "d3946db9903c0c31" }, { "metadata": {}, @@ -2327,7 +2327,7 @@ "outputs": [], "execution_count": null, "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "d22f105417c2c01d" + "id": "97f6b629764da311" }, { "metadata": {}, @@ -2335,7 +2335,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "bf8fca3366efc22f" + "id": "2db2d45897f8c287" }, { "metadata": {}, @@ -2354,7 +2354,7 @@ "\n", "foo.to(param='value')" ], - "id": "ebc3af6ec23fd542" + "id": "a13dde8eb8b6b16c" }, { "metadata": {}, @@ -2370,7 +2370,7 @@ "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" ], - "id": "b39169f9cd9bc2f8" + "id": "f85d688757db4d7b" }, { "metadata": {}, @@ -2383,7 +2383,7 @@ "\n", "foo.to(param='value')" ], - "id": "c36bb973535203d9" + "id": "61d6fe1d2dbd5a25" }, { "metadata": {}, @@ -2399,7 +2399,7 @@ "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" ], - "id": "5970130150747a86" + "id": "7209021af6a2190e" }, { "metadata": {}, @@ -2411,7 +2411,7 @@ " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" ], - "id": "a7cf7a576673d95a" + "id": "fd987bab1066e1c9" }, { "metadata": {}, @@ -2433,7 +2433,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "2ac41910c4bf14a4" + "id": "b9313ba355e1fb06" }, { "metadata": {}, @@ -2448,7 +2448,7 @@ "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" ], - "id": "537123019a9f3ab8" + "id": "283c28edbb3b407a" }, { "metadata": {}, @@ -2462,7 +2462,7 @@ "r = cli.get('/hi')\n", "r.text" ], - "id": "38ca3e0dd9abb4c7" + "id": "a5fa0258070685cc" }, { "metadata": {}, @@ -2475,7 +2475,7 @@ "\n", "cli.post('/hi').text" ], - "id": "10abe7aed1d9087" + "id": "24ae998024bb0e9b" }, { "metadata": {}, @@ -2488,7 +2488,7 @@ "\n", "cli.get('/hostie').text" ], - "id": "85fe9c57b38a3c5b" + "id": "f89bce612cf6b114" }, { "metadata": {}, @@ -2511,7 +2511,7 @@ "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" ], - "id": "93b36f3c165a9a0" + "id": "1852e7ae89c3ebd" }, { "metadata": {}, @@ -2524,7 +2524,7 @@ "\n", "cli.post('/yoyo').text" ], - "id": "e6952fc52bbd3df" + "id": "df7dfb3c8dc63ecc" }, { "metadata": {}, @@ -2536,7 +2536,7 @@ "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" ], - "id": "6e6ae6afba5edb9b" + "id": "e21e967ce097bf10" }, { "metadata": {}, @@ -2548,7 +2548,7 @@ "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" ], - "id": "8683c2a58eb44875" + "id": "a0efb7dc288753fc" }, { "metadata": {}, @@ -2560,7 +2560,7 @@ "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" ], - "id": "450a74fd2a57be1d" + "id": "f0e3b533ed6f45c7" }, { "metadata": {}, @@ -2572,7 +2572,7 @@ "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" ], - "id": "9b1d7847cc216aeb" + "id": "b1d218f86454edcd" }, { "metadata": {}, @@ -2584,7 +2584,7 @@ "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" ], - "id": "dd5b86638148748c" + "id": "10cf11188506e731" }, { "metadata": {}, @@ -2597,7 +2597,7 @@ "\n", "cli.get('/link').text" ], - "id": "e3d8e0c524267200" + "id": "6e5197fe7b759fb" }, { "metadata": {}, @@ -2614,7 +2614,7 @@ "\n", "response = cli.get(\"/background\")" ], - "id": "965bf11ea523d815" + "id": "a411d585d37c6b20" }, { "metadata": {}, @@ -2622,7 +2622,7 @@ "outputs": [], "execution_count": null, "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "96830d1382863f7" + "id": "f364cb1a04a03aca" }, { "metadata": {}, @@ -2652,7 +2652,7 @@ "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" ], - "id": "42ae0ec543eb889a" + "id": "d612a4818affa3b9" }, { "metadata": {}, @@ -2666,7 +2666,7 @@ "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" ], - "id": "8735892e65fa4dac" + "id": "7a172c72f3f8e612" }, { "metadata": {}, @@ -2678,7 +2678,7 @@ "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" ], - "id": "ad6d485116daff23" + "id": "7b25f9ca907e9f5" }, { "metadata": {}, @@ -2689,7 +2689,7 @@ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" ], - "id": "b239bec977272f22" + "id": "319cd06919776162" }, { "metadata": {}, @@ -2700,7 +2700,7 @@ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" ], - "id": "23dcde1f24d540e8" + "id": "1be7b1965340a047" }, { "metadata": {}, @@ -2726,7 +2726,7 @@ "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" ], - "id": "ac9d6f976176cace" + "id": "f4c24fb6058a6e54" }, { "metadata": {}, @@ -2747,7 +2747,7 @@ "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" ], - "id": "4a3227cd8c38816f" + "id": "9d6412d097761778" }, { "metadata": {}, @@ -2764,7 +2764,7 @@ "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" ], - "id": "3b5286cf648f699b" + "id": "533da48ca6996e4d" }, { "metadata": {}, @@ -2772,7 +2772,7 @@ "outputs": [], "execution_count": null, "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "debe4e5303996beb" + "id": "679493fc6e010dcb" }, { "metadata": {}, @@ -2780,7 +2780,7 @@ "outputs": [], "execution_count": null, "source": "app.chk = 'foo'", - "id": "49ac7de43ebe3c57" + "id": "25947574fb51212f" }, { "metadata": {}, @@ -2818,7 +2818,7 @@ "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" ], - "id": "775b8d9740d334fe" + "id": "14d43f169641ddb6" }, { "metadata": {}, @@ -2840,7 +2840,7 @@ " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" ], - "id": "108d32faefb1a6d2" + "id": "e270772d70e64738" }, { "metadata": {}, @@ -2852,7 +2852,7 @@ "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" ], - "id": "2a6b2fdf20e88b8f" + "id": "7fa38760a2c4c2b9" }, { "metadata": {}, @@ -2863,7 +2863,7 @@ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" ], - "id": "2c203425ce24ca42" + "id": "bff4f4ad08765cb0" }, { "metadata": {}, @@ -2874,7 +2874,7 @@ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" ], - "id": "8b033491dd8e1f8b" + "id": "5e28d8c24a7c39d9" }, { "metadata": {}, @@ -2885,7 +2885,7 @@ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" ], - "id": "296529e338b70512" + "id": "124d00997ca5442" }, { "metadata": {}, @@ -2900,7 +2900,7 @@ "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" ], - "id": "22c2933ef58a13b7" + "id": "7d153de0eeb79b58" }, { "metadata": {}, @@ -3028,7 +3028,7 @@ "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" ], - "id": "c95c9b2725f812c" + "id": "7af9ca8355488281" }, { "metadata": {}, @@ -3036,7 +3036,7 @@ "outputs": [], "execution_count": null, "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "9f1062ca667d55ac" + "id": "a3d393106d3e9ba7" }, { "metadata": {}, @@ -3051,7 +3051,7 @@ "print(res.status_code)\n", "print(res.text)" ], - "id": "d8382518ab22e380" + "id": "c64599ca56c426a" }, { "metadata": {}, @@ -3063,7 +3063,7 @@ "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" ], - "id": "224bb711ceb17e33" + "id": "41a1daf7b482f070" }, { "metadata": {}, @@ -3085,7 +3085,7 @@ "\n", "cli.get('/getsess').text" ], - "id": "c7830d14c7f1d876" + "id": "92675c4d030a3259" }, { "metadata": {}, @@ -3105,7 +3105,7 @@ "\n", "test_eq(cli.get('/getsess-all').text, '2')" ], - "id": "f5f7d7022a04e6f2" + "id": "c7056c6628637864" }, { "metadata": {}, @@ -3118,7 +3118,7 @@ "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" ], - "id": "e4da9610fb117ee3" + "id": "2b0edb94045b1b70" }, { "metadata": {}, @@ -3131,7 +3131,7 @@ " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" ], - "id": "4d74166bbb762323" + "id": "e85e1be2cb22092c" }, { "metadata": {}, @@ -3142,7 +3142,7 @@ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" ], - "id": "d4428f4af5f67ce8" + "id": "6df13741986b614a" }, { "metadata": {}, @@ -3150,7 +3150,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.authmw import user_pwd_auth", - "id": "90fd8966d3dd0dd8" + "id": "d44cca86642dbb96" }, { "metadata": {}, @@ -3166,7 +3166,7 @@ "assert '
nope
' in txt\n", "assert '' in txt" ], - "id": "51b0eebf106c8905" + "id": "cb5ccd1b519ff618" }, { "metadata": {}, @@ -3182,7 +3182,7 @@ "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" ], - "id": "6f8a06ba6870021f" + "id": "12a2f2754d170003" }, { "metadata": {}, @@ -3199,7 +3199,7 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "c7ebeaff94af317e" + "id": "551a5adf263fa813" }, { "metadata": {}, @@ -3216,13 +3216,13 @@ "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" ], - "id": "1ebaff5d97bf768b" + "id": "ef8748272d98da81" }, { "metadata": {}, "cell_type": "markdown", "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", - "id": "ae13a2534afe29a2" + "id": "49d680d4c413cfcb" }, { "metadata": {}, @@ -3230,7 +3230,7 @@ "outputs": [], "execution_count": null, "source": "from fasthtml.toaster import *", - "id": "f09dea1b7cc7765d" + "id": "df8ff4ec8f55fe2" }, { "metadata": {}, @@ -3259,13 +3259,13 @@ "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" ], - "id": "65e5e4d819b9e20c" + "id": "cedbb3bedf22c504" }, { "metadata": {}, "cell_type": "markdown", "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "408406de6a274cf9" + "id": "273c37c465affa62" }, { "metadata": {}, @@ -3273,7 +3273,7 @@ "outputs": [], "execution_count": null, "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "3bb8881330355d27" + "id": "ea958d51eda2f8fc" }, { "metadata": {}, @@ -3290,7 +3290,7 @@ "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" ], - "id": "d642c1f1d15bb4c" + "id": "1afbcb5da9793eb9" }, { "metadata": {}, @@ -3308,13 +3308,13 @@ "\n", "with cli: test_eq(cli.get('/').text, 'fired')" ], - "id": "778d773193ca4ff8" + "id": "88360e6d644ab9d3" }, { "metadata": {}, "cell_type": "markdown", "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "821c39b743c32cb8" + "id": "8900d323d981c71d" }, { "metadata": {}, @@ -3336,13 +3336,13 @@ "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" ], - "id": "37432ea78ba02b9d" + "id": "c9f08a6740f5ac09" }, { "metadata": {}, "cell_type": "markdown", "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "3dfebc3f359634ad" + "id": "88fbbfc231b8d60f" }, { "metadata": {}, @@ -3358,13 +3358,13 @@ "\n", "test_eq(cli.get('/multi').text, '1,2')" ], - "id": "bee2b8c7a8fd728a" + "id": "413103e9e06d1f76" }, { "metadata": {}, "cell_type": "markdown", "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "f558641587e4bab0" + "id": "566d13c827e388d9" }, { "metadata": {}, @@ -3384,13 +3384,13 @@ "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" ], - "id": "e9a5239078afee2" + "id": "978cac3d69ba52a5" }, { "metadata": {}, "cell_type": "markdown", "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "c760bae67e2139dd" + "id": "269b3e53bde24b5" }, { "metadata": {}, @@ -3406,7 +3406,7 @@ "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" ], - "id": "1dd96d9ef0acc2a1" + "id": "67a91b66a7b62d01" }, { "metadata": {}, @@ -3422,13 +3422,13 @@ "\n", "test_fail(lambda: cli.get('/sleepy'))" ], - "id": "5e6783f1443a8fc9" + "id": "422123f1a058c2a7" }, { "metadata": {}, "cell_type": "markdown", "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "c0e2a8564dc5f298" + "id": "e6252b6b2a99767e" }, { "metadata": {}, @@ -3453,13 +3453,13 @@ "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" ], - "id": "5a8c9d2d917519d7" + "id": "56c1535ac76e35fe" }, { "metadata": {}, "cell_type": "markdown", "source": "## APIRouter", - "id": "7d4e1498760dd859" + "id": "b4c13346baae5c6" }, { "metadata": {}, @@ -3478,7 +3478,7 @@ " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" ], - "id": "cd31207457efd4a6" + "id": "2fee904cd591a8c3" }, { "metadata": {}, @@ -3526,7 +3526,7 @@ " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" ], - "id": "e024bfae1effc7f8" + "id": "303886be15d05943" }, { "metadata": {}, @@ -3534,7 +3534,7 @@ "outputs": [], "execution_count": null, "source": "ar = APIRouter()", - "id": "8503fae77f6b634b" + "id": "c1dcefcdcfffdc93" }, { "metadata": {}, @@ -3564,7 +3564,7 @@ "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "458cb8e0210a638c" + "id": "fce8a8e52c493277" }, { "metadata": {}, @@ -3575,7 +3575,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" ], - "id": "891b17a0715b1f3e" + "id": "9556fa722d0e5beb" }, { "metadata": {}, @@ -3593,7 +3593,7 @@ "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" ], - "id": "e938cdbd7ffd2683" + "id": "d9389522e7a6bcfa" }, { "metadata": {}, @@ -3614,7 +3614,7 @@ "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" ], - "id": "4048ab20b567acf" + "id": "429c74a5b46e3521" }, { "metadata": {}, @@ -3627,7 +3627,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "cb560378ae3b5bd0" + "id": "4e2dcfb51498c6f" }, { "metadata": {}, @@ -3635,7 +3635,7 @@ "outputs": [], "execution_count": null, "source": "ar2 = APIRouter(\"/products\")", - "id": "4470384a3b80253f" + "id": "a5c4c82857be6a37" }, { "metadata": {}, @@ -3663,7 +3663,7 @@ "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" ], - "id": "323357c9713b9904" + "id": "ed31b4ccef71816a" }, { "metadata": {}, @@ -3674,7 +3674,7 @@ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" ], - "id": "e33e529f57d383d3" + "id": "f6f034517d061c12" }, { "metadata": {}, @@ -3691,7 +3691,7 @@ "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" ], - "id": "7209fa015b93ec8e" + "id": "cbbcda8c607b8fd7" }, { "metadata": {}, @@ -3710,7 +3710,7 @@ "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" ], - "id": "c6efccb37def7269" + "id": "f545be46e68f5ca6" }, { "metadata": {}, @@ -3723,7 +3723,7 @@ " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" ], - "id": "f4cc54ca0a001665" + "id": "4a15b327c1a8628b" }, { "metadata": {}, @@ -3734,7 +3734,7 @@ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" ], - "id": "1135e59c6ef81829" + "id": "58bb7ea442d40849" }, { "metadata": {}, @@ -3756,13 +3756,13 @@ "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" ], - "id": "c0557abd43cd598b" + "id": "716a5164e7e652cc" }, { "metadata": {}, "cell_type": "markdown", "source": "## Extras", - "id": "3ddb613fb2e14125" + "id": "59b28a4c87902015" }, { "metadata": {}, @@ -3770,7 +3770,7 @@ "outputs": [], "execution_count": null, "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "67aae9f06b7be50a" + "id": "548666a35366fbb9" }, { "metadata": {}, @@ -3795,7 +3795,7 @@ " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" ], - "id": "165efefd99a8d157" + "id": "33d84011f283d35a" }, { "metadata": {}, @@ -3813,7 +3813,7 @@ "time.sleep(0.01)\n", "cli.get('/getcookie').text" ], - "id": "76d9a9b30ba024a1" + "id": "f3b4174a290eb26d" }, { "metadata": {}, @@ -3827,7 +3827,7 @@ " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" ], - "id": "361ac1ea84f244db" + "id": "6922cff2253eefbf" }, { "metadata": {}, @@ -3847,7 +3847,7 @@ " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" ], - "id": "188cbe278772a153" + "id": "6bcdf84ea9fade04" }, { "metadata": {}, @@ -3862,7 +3862,7 @@ "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" ], - "id": "2d5d9474e3affcbc" + "id": "cd88cb3834fd88d1" }, { "metadata": {}, @@ -3873,7 +3873,7 @@ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" ], - "id": "131cb1705b1a3983" + "id": "c73ffbfbf21a7c85" }, { "metadata": {}, @@ -3888,7 +3888,7 @@ " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" ], - "id": "82d00e836bb451fd" + "id": "50859aff05c795" }, { "metadata": {}, @@ -3899,7 +3899,7 @@ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ], - "id": "43c779f084baa19b" + "id": "5a845e1896123499" }, { "metadata": {}, @@ -3915,7 +3915,7 @@ " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" ], - "id": "bd60c9f4046e839f" + "id": "aa20e2aaf3c31651" }, { "metadata": {}, @@ -3931,13 +3931,13 @@ " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" ], - "id": "f5fbf53748deb383" + "id": "12b070d471b17752" }, { "metadata": {}, "cell_type": "markdown", "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "66fc62210e475fa4" + "id": "10111d01584e2fed" }, { "metadata": {}, @@ -3952,7 +3952,7 @@ "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" ], - "id": "a571da6de24bc873" + "id": "5e643155b44b7f53" }, { "metadata": {}, @@ -3971,13 +3971,13 @@ " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" ], - "id": "735144d2837c118" + "id": "8456c4ce3b137c6" }, { "metadata": {}, "cell_type": "markdown", "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "9bd8ff9f101bf1a" + "id": "e92816df7c68ad9e" }, { "metadata": {}, @@ -3997,7 +3997,7 @@ "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" ], - "id": "123a17ac6bceedfa" + "id": "d93300e58bf96cf2" }, { "metadata": {}, @@ -4009,7 +4009,7 @@ "from functools import wraps\n", "from inspect import signature, isawaitable" ], - "id": "906f0140ec0c7916" + "id": "532bd72387550a77" }, { "metadata": {}, @@ -4028,7 +4028,7 @@ " f.__annotations__[name] = typ\n", " return f" ], - "id": "73bd66558e445a9f" + "id": "93f8c1ac3ae4612" }, { "metadata": {}, @@ -4058,19 +4058,19 @@ " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" ], - "id": "63813016a6b7f21d" + "id": "6672281bf9d8b0c6" }, { "metadata": {}, "cell_type": "markdown", "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "a9d329021dcc9795" + "id": "805c0d5c6e5300f9" }, { "metadata": {}, "cell_type": "markdown", "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "f4670220611a15d5" + "id": "67d2afef245c1277" }, { "metadata": {}, @@ -4086,13 +4086,13 @@ "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" ], - "id": "e390e30eabaa7dff" + "id": "b149ac59a7a9b0e0" }, { "metadata": {}, "cell_type": "markdown", "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "1c9b67665004aee2" + "id": "fc41c37ab4b42c3" }, { "metadata": {}, @@ -4104,13 +4104,13 @@ "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" ], - "id": "2bd1cae87d69b116" + "id": "9ed0441e7450245e" }, { "metadata": {}, "cell_type": "markdown", "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "b98cf89f4891635a" + "id": "d84f8ac0b32564eb" }, { "metadata": {}, @@ -4122,7 +4122,7 @@ "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" ], - "id": "ba212aeee60afe52" + "id": "9c7aca3c694ca5be" }, { "metadata": {}, @@ -4138,7 +4138,7 @@ " await self._app(scope, receive, send)\n", " " ], - "id": "1096e43481864dfd" + "id": "31f7ab28650f32bc" }, { "metadata": {}, @@ -4161,7 +4161,7 @@ " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" ], - "id": "36273444869b2cc7" + "id": "8a73b905220e2a4e" }, { "metadata": {}, @@ -4181,13 +4181,13 @@ "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" ], - "id": "2a58891859236be6" + "id": "98bdffbc0fdf2242" }, { "metadata": {}, "cell_type": "markdown", "source": "Test on a single background task:", - "id": "281125b7dac64fb" + "id": "cf7364932d6c113b" }, { "metadata": {}, @@ -4208,13 +4208,13 @@ "test_eq(r.status_code, 200)\n", "\n" ], - "id": "153e7c1b259d4afa" + "id": "9bedecb86cf6ade6" }, { "metadata": {}, "cell_type": "markdown", "source": "Test multiple background tasks:", - "id": "b0c1219aa2b25514" + "id": "b2d690b86e2095fe" }, { "metadata": {}, @@ -4228,7 +4228,7 @@ " time.sleep(amount)\n", " print(f'Slept for {amount}s') " ], - "id": "8c9e6dfa03f52b5b" + "id": "3e8dde53f0ffe20d" }, { "metadata": {}, @@ -4245,7 +4245,7 @@ "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" ], - "id": "c44564dc6d81b45f" + "id": "f0b4f8de33eb6a7c" }, { "metadata": {}, @@ -4261,7 +4261,7 @@ "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" ], - "id": "1a04c4d0ba03cfca" + "id": "e16949201c9fd37d" }, { "metadata": {}, @@ -4278,7 +4278,7 @@ "test_eq(r.status_code, 200)\n", "r.json()" ], - "id": "75841e9b6f563b3e" + "id": "28bf53e773c2508a" }, { "metadata": {}, @@ -4293,7 +4293,7 @@ " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" ], - "id": "4a0a018ee7e76f63" + "id": "ffe9ca3ec6694f2e" }, { "metadata": {}, @@ -4307,7 +4307,7 @@ " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" ], - "id": "4253d155b80c4556" + "id": "d26ac72480caf92f" }, { "metadata": {}, @@ -4327,13 +4327,13 @@ " app._send = send\n", " return send" ], - "id": "c1f7865ab79a4ff1" + "id": "ce9ce4311e72df65" }, { "metadata": {}, "cell_type": "markdown", "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", - "id": "831365f12b3d6749" + "id": "442ea36df3e1417f" }, { "metadata": {}, @@ -4350,7 +4350,7 @@ " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" ], - "id": "c756dbb3979f83bd" + "id": "1b0edc880dc5c73a" }, { "metadata": {}, @@ -4368,7 +4368,7 @@ " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" ], - "id": "a25f4a9f7e1baed5" + "id": "2f4989eb010bc1bb" }, { "metadata": {}, @@ -4388,7 +4388,7 @@ " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" ], - "id": "36b8ea0df4d3f632" + "id": "a93ba5a91685e0b2" }, { "metadata": {}, @@ -4404,7 +4404,7 @@ " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" ], - "id": "6f633e5e9e8b23ab" + "id": "5f14a24a1fc21cda" }, { "metadata": {}, @@ -4427,7 +4427,7 @@ "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" ], - "id": "89568352973aa146" + "id": "ba3d97d61961e0f4" }, { "metadata": {}, @@ -4438,13 +4438,13 @@ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" ], - "id": "3ccded46057fcce9" + "id": "a7f08c6be1b8889d" }, { "metadata": {}, "cell_type": "markdown", "source": "## Export -", - "id": "2b322ba467728be8" + "id": "48e4bdd27c31e33a" }, { "metadata": {}, @@ -4456,7 +4456,7 @@ "import nbdev\n", "nbdev.nbdev_export()" ], - "id": "65740f4518856bff" + "id": "925c4a8102e23e54" } ], "metadata": { From b5b9b9952de2475eceba662848bffb52048a66f3 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Wed, 21 Oct 2026 11:48:54 -0300 Subject: [PATCH 18/22] refactor: improve request parameter handling and update documentation  Conflicts:  fasthtml/core.py  nbs/api/00_core.ipynb --- fasthtml/core.py | 298 +++--- nbs/api/00_core.ipynb | 2382 ++++++++++++++++++++++------------------- 2 files changed, 1412 insertions(+), 1268 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 44ea3997..3a462819 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -18,7 +18,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. -Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: +Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. To read a different request name, use `Annotated[T, Alias('name')]`. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: req, ws the Request / WebSocket connection sess session dict @@ -48,13 +48,13 @@ class Cards: ... # class route group: each verb-named method (get, patch, . __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', - 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', - 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', - 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', 'RouteFuncs', - 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', - 'MiddlewareBase', 'FtResponse', 'unqid'] + 'JSONResponse', 'Alias', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', + 'flat_tuple', 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', + 'FastHTML', 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', + 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', + 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] -# %% ../nbs/api/00_core.ipynb #23503b9e +# %% ../nbs/api/00_core.ipynb #c9ebcfc30a1f800 import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess from uuid import uuid5, NAMESPACE_URL @@ -64,7 +64,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from fastcore.style import S from types import UnionType, SimpleNamespace as ns, GenericAlias -from typing import get_args, get_origin, Union, Mapping, List, Any, Callable +from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated from datetime import datetime,date from dataclasses import dataclass from inspect import Parameter,get_annotations @@ -82,23 +82,23 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from .starlette import * from python_multipart.exceptions import MultipartParseError -# %% ../nbs/api/00_core.ipynb #19d3f2a7 +# %% ../nbs/api/00_core.ipynb #812ef9183ad0b657 def _params(f): return signature_ex(f, True).parameters empty = Parameter.empty -# %% ../nbs/api/00_core.ipynb #e68a76c9 +# %% ../nbs/api/00_core.ipynb #c5028c09a628533 def parsed_date(s:str): "Convert `s` to a datetime" return dtparse.parse(s) -# %% ../nbs/api/00_core.ipynb #7c820373 +# %% ../nbs/api/00_core.ipynb #160b6878e8a6a74d def snake2hyphens(s:str): "Convert `s` from snake case to hyphenated and capitalised" s = snake2camel(s) return camel2words(s, '-') -# %% ../nbs/api/00_core.ipynb #25f3b8f8 +# %% ../nbs/api/00_core.ipynb #31fb5e7526f55d26 htmx_hdrs = dict( boosted="HX-Boosted", current_url="HX-Current-URL", @@ -119,17 +119,23 @@ class HtmxHeaders: trigger_name:str|None=None; trigger:str|None=None def __bool__(self): return any(hasattr(self,o) for o in htmx_hdrs) +def _get_header(h, k): + k = k.lower() + res = h.get(k) + encoded = h.get(k+'-uri-autoencoded')=='true' or (k=='hx-prompt' and h.get('hx-request-type')) + return unquote(res) if res is not None and encoded else res + def _get_htmx(h): - res = {k:h.get(v.lower(), None) for k,v in htmx_hdrs.items()} + res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()} return HtmxHeaders(**res) -# %% ../nbs/api/00_core.ipynb #1d53e8e7 +# %% ../nbs/api/00_core.ipynb #6a06097681bcd6a6 def _mk_list(t, v): return [t(o) for o in listify(v)] -# %% ../nbs/api/00_core.ipynb #5ab74473 +# %% ../nbs/api/00_core.ipynb #12f93b7aa5424194 fh_cfg = AttrDict(indent=True) -# %% ../nbs/api/00_core.ipynb #88d12e68 +# %% ../nbs/api/00_core.ipynb #58660171446e4348 _special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'} def _check_anno(arg, anno): @@ -137,7 +143,7 @@ def _check_anno(arg, anno): if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f"`{arg}` has no type annotation and is not a recognised special name, so is ignored." if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead)." -# %% ../nbs/api/00_core.ipynb #0afb520c +# %% ../nbs/api/00_core.ipynb #a6c4d5dec9a11382 def _fix_anno(t, o): "Create appropriate callable type for casting a `str` to type `t` (or first type in `t` if union)" origin = get_origin(t) @@ -150,7 +156,7 @@ def _fix_anno(t, o): if not isinstance(o, (str,list,tuple)): return o return res(o[-1]) if isinstance(o,(list,tuple)) else res(o) -# %% ../nbs/api/00_core.ipynb #c58ccadb +# %% ../nbs/api/00_core.ipynb #10852e6f03bb7231 def _form_arg(k, v, d): "Get type by accessing key `k` from `d`, and use to cast `v`" if v is None: return @@ -160,51 +166,52 @@ def _form_arg(k, v, d): if not anno: return v return _fix_anno(anno, v) -# %% ../nbs/api/00_core.ipynb #5fc04751 +# %% ../nbs/api/00_core.ipynb #b40f180dae734a89 @dataclass class HttpHeader: "A response header as a k,v pair" k:str;v:str -# %% ../nbs/api/00_core.ipynb #94e18161 +# %% ../nbs/api/00_core.ipynb #3cf407bbe6d72fce def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title() htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None, reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None) -# %% ../nbs/api/00_core.ipynb #f6a2e62e +# %% ../nbs/api/00_core.ipynb #2cf50288f5a348f @use_kwargs_dict(**htmx_resps) def HtmxResponseHeaders(**kwargs): "HTMX response headers" res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items()) return res[0] if len(res)==1 else res -# %% ../nbs/api/00_core.ipynb #56cc589f +# %% ../nbs/api/00_core.ipynb #922afe94af9a5100 def _annotations(anno): "Same as `get_annotations`, but also works on namedtuples" if is_namedtuple(anno): return {o:str for o in anno._fields} return get_annotations(anno) -# %% ../nbs/api/00_core.ipynb #cb4ed4aa +# %% ../nbs/api/00_core.ipynb #5a1fa453f1caafa7 def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno) -# %% ../nbs/api/00_core.ipynb #ffdde66f +# %% ../nbs/api/00_core.ipynb #84fe8f035ba3132d def _formitem(form, k): - "Return single item `k` from `form` if len 1, otherwise return list" + "Return item `k`, accepting hyphens for underscores and unwrapping single-item form lists" + if k not in form: k = {o.replace('-', '_'):o for o in form}.get(k, k) if isinstance(form, dict): return form.get(k) o = form.getlist(k) return o[0] if len(o) == 1 else o if o else None -# %% ../nbs/api/00_core.ipynb #5fe74444 +# %% ../nbs/api/00_core.ipynb #ba55a6ab2bf79e81 def form2dict(form: FormData) -> dict: "Convert starlette form data to a dict" if isinstance(form, dict): return form return {k: _formitem(form, k) for k in form} -# %% ../nbs/api/00_core.ipynb #8899bc0a +# %% ../nbs/api/00_core.ipynb #7154b82a09979e0a DEF_MAXPART = 100*1024*1024 -# %% ../nbs/api/00_core.ipynb #42c9cea0 +# %% ../nbs/api/00_core.ipynb #1f95924092f9c33d async def parse_form(req: Request) -> FormData: "Starlette errors on empty multipart/json forms and 500s on malformed multipart, so this handles those situations" ctype = req.headers.get("Content-Type", "") @@ -223,7 +230,7 @@ async def parse_form(req: Request) -> FormData: return res return await req.form(max_part_size=maxpart) -# %% ../nbs/api/00_core.ipynb #0caedd04 +# %% ../nbs/api/00_core.ipynb #8e8b9056b2f3a099 async def _from_body(conn, p, data): "Create an instance of the annotated type from pre-parsed `data`" anno = p.annotation @@ -235,10 +242,11 @@ async def _from_body(conn, p, data): kwargs = await _find_ps(conn, data, conn.headers, ps) return await maybe_await(ctor(**kwargs)) d = _annotations(anno) + if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d} return anno(**cargs) -# %% ../nbs/api/00_core.ipynb #88b6da3f +# %% ../nbs/api/00_core.ipynb #a73dc9076461a6a9 class ApiReturn: "Request param: call with browser and API responses; returns kwargs as JSON if the client accepts `application/json`, else `norm`" @classmethod @@ -247,7 +255,7 @@ def __init__(self, isapi=False): self.isapi = isapi def __call__(self, norm=None, **kw): return kw if self.isapi else norm def __bool__(self): return bool(self.isapi) -# %% ../nbs/api/00_core.ipynb #7cc39ba9 +# %% ../nbs/api/00_core.ipynb #c835e81d6e4f5efd class JSONResponse(JSONResponseOrig): "Same as starlette's version, but auto-stringifies non serializable types" def render(self, content:Any)->bytes: @@ -256,9 +264,20 @@ def _default(o): return list(o) if is_listy(o) else str(o) return res.encode("utf-8") -# %% ../nbs/api/00_core.ipynb #5fa96e3a +# %% ../nbs/api/00_core.ipynb #4e1d265576d2d438 +@dataclass(frozen=True) +class Alias: + "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" + name: str + async def _find_p(conn, data, hdrs, arg:str, p:Parameter): "In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)" + alias = None + if get_origin(p.annotation) is Annotated: + anno, *meta = get_args(p.annotation) + p = p.replace(annotation=anno) + alias = next((m.name for m in meta if isinstance(m, Alias)), None) + key = alias or arg anno = p.annotation # Special annotation types if isinstance(anno, type) and not isinstance(anno, GenericAlias): @@ -268,55 +287,54 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if issubclass(anno, State): return conn.scope['app'].state if anno is dict: return data if _is_body(anno): - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) return await _from_body(conn, p, data) - if (msg := _check_anno(arg, anno)): return warn(msg) + if (msg := _check_anno(key, anno)): return warn(msg) # Special param names with no annotations if anno is empty: - if arg.lower()=='ws' or 'request'.startswith(arg.lower()): return conn - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) - if arg.lower()=='scope': return conn.scope - if arg.lower()=='data': return data - if arg.lower()=='htmx': return _get_htmx(hdrs) - if arg.lower()=='app': return conn.scope['app'] - if arg.lower()=='state': return conn.scope['app'].state - if arg.lower()=='auth': return conn.scope.get('auth', None) - if arg.lower()=='send': + if key.lower()=='ws' or 'request'.startswith(key.lower()): return conn + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) + if key.lower()=='scope': return conn.scope + if key.lower()=='data': return data + if key.lower()=='htmx': return _get_htmx(hdrs) + if key.lower()=='app': return conn.scope['app'] + if key.lower()=='state': return conn.scope['app'].state + if key.lower()=='auth': return conn.scope.get('auth', None) + if key.lower()=='send': assert not isinstance(conn, Request), "`send` requires a websocket, not a `Request`" return partial(_send_ws, conn) - if arg.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json') - if arg.lower()=='body': return (await conn.body()).decode() - if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) + if key.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json') + if key.lower()=='body': return (await conn.body()).decode() + if key.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, key.lower()) return None # Not a special name or a special annotation - res = conn.path_params.get(arg, None) - if res in (empty,None): res = conn.cookies.get(arg, None) + res = conn.path_params.get(key, None) + if res in (empty,None): res = conn.cookies.get(key, None) if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None) - if res in (empty,None): res = conn.query_params.getlist(arg) - if res==[]: res = None - if res in (empty,None): res = data.get(arg, None) - if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(arg) # htmx v4 nests form data under `values` + if res in (empty,None): res = _formitem(conn.query_params, key) + if res in (empty,None): res = _formitem(data, key) + if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` if res in (empty,None): if p.default is empty: - if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") - raise ValueError(f"Missing required field: {arg}") + if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {key}") + raise ValueError(f"Missing required field: {key}") res = p.default try: return _fix_anno(anno, res) except ValueError as e: if isinstance(conn, Request): raise HTTPException(404, f"{conn.url.path}: {e}") from None raise -# %% ../nbs/api/00_core.ipynb #bf42edad +# %% ../nbs/api/00_core.ipynb #40f862f356ae9ebb async def _find_ps(conn, data, hdrs, params): if conn.query_params: data |= dict(conn.query_params) return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()} -# %% ../nbs/api/00_core.ipynb #090f1f0f +# %% ../nbs/api/00_core.ipynb #f88072f39efb683e async def _wrap_req(req, params): data = form2dict(await parse_form(req)) return await _find_ps(req, data, req.headers, params) -# %% ../nbs/api/00_core.ipynb #7a661bfa +# %% ../nbs/api/00_core.ipynb #2891fffa291ffaa9 def flat_xt(lst): "Flatten lists" result = [] @@ -326,23 +344,23 @@ def flat_xt(lst): else: result.append(item) return tuple(result) -# %% ../nbs/api/00_core.ipynb #aacff5ac +# %% ../nbs/api/00_core.ipynb #7f97f41c7180179a class Beforeware: "Wrap a `before` function with `skip` path patterns that bypass it" def __init__(self, f, skip=None): self.f,self.skip = f,skip or [] def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})' -# %% ../nbs/api/00_core.ipynb #78c3c357 +# %% ../nbs/api/00_core.ipynb #24171b42d8ed41ee async def _handle(f, /, *args, **kwargs): return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs) -# %% ../nbs/api/00_core.ipynb #ad0f0e87 +# %% ../nbs/api/00_core.ipynb #dc78b1c31eb67d83 async def _wrap_ws(ws, data, params): raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers` hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None}) return await _find_ps(ws, data, hdrs, params) -# %% ../nbs/api/00_core.ipynb #dcc15129 +# %% ../nbs/api/00_core.ipynb #29df49dfbdbeab89 async def _send_ws(ws, resp): if not resp: return res = to_xml(resp, indent=fh_cfg.indent) @@ -370,12 +388,12 @@ async def _recv(self, ws, data): await _generic_handler(recv, ws, data) cls.on_receive = _recv return cls -# %% ../nbs/api/00_core.ipynb #5b0e7677 +# %% ../nbs/api/00_core.ipynb #83fa302372e0e042 def EventStream(s): "Create a text/event-stream response from `s`" return StreamingResponse(s, media_type="text/event-stream") -# %% ../nbs/api/00_core.ipynb #0dd0a414 +# %% ../nbs/api/00_core.ipynb #f588429f00c732e7 def signal_shutdown(): "Return an `asyncio.Event` set when uvicorn receives a shutdown signal" from uvicorn.main import Server @@ -387,21 +405,21 @@ def handle_exit(self:Server, *args, **kwargs): self._orig_handle_exit(*args, **kwargs) return event -# %% ../nbs/api/00_core.ipynb #03f4c639 +# %% ../nbs/api/00_core.ipynb #1537735647b1227f def uri(_arg, **kwargs): "Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`" return f"{quote(_arg)}/{urlencode(kwargs, doseq=True)}" -# %% ../nbs/api/00_core.ipynb #bdb2ac14 +# %% ../nbs/api/00_core.ipynb #3004b7d11797405c def decode_uri(s): "Decode a URI created by `uri()` back into argument and keyword dict" arg,_,kw = s.partition('/') return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()} -# %% ../nbs/api/00_core.ipynb #b80ce139 +# %% ../nbs/api/00_core.ipynb #e9de9e161764947b from starlette.convertors import StringConvertor -# %% ../nbs/api/00_core.ipynb #a27adc1e +# %% ../nbs/api/00_core.ipynb #b9bb3ffe99344740 StringConvertor.regex = "[^/]*" # `+` replaced with `*` @patch @@ -411,13 +429,13 @@ def to_string(self:StringConvertor, value: str) -> str: # assert value, "Must not be empty" # line removed due to errors return value -# %% ../nbs/api/00_core.ipynb #46614165 +# %% ../nbs/api/00_core.ipynb #cccf24a4868dd227 @patch def url_path_for(self:HTTPConnection, name: str, **path_params): lp = self.scope['app'].url_path_for(name, **path_params) return URLPath(f"{self.scope['root_path']}{lp}", lp.protocol, lp.host) -# %% ../nbs/api/00_core.ipynb #c1707d59 +# %% ../nbs/api/00_core.ipynb #535fa019103e6f9d _verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href') def _url_for(req, t): @@ -443,10 +461,10 @@ def _to_xml(req, resp, indent): _find_targets(req, resp) return to_xml(resp, indent=indent) -# %% ../nbs/api/00_core.ipynb #f1e3ed2d +# %% ../nbs/api/00_core.ipynb #69d2044af9aa17d4 _iter_typs = (tuple,list,map,filter,range,types.GeneratorType) -# %% ../nbs/api/00_core.ipynb #a407dc0e +# %% ../nbs/api/00_core.ipynb #fecf15a81dec5af7 def flat_tuple(o): "Flatten nested iterables into a single tuple" result = [] @@ -457,12 +475,12 @@ def flat_tuple(o): else: result.append(item) return tuple(result) -# %% ../nbs/api/00_core.ipynb #d36402e6 +# %% ../nbs/api/00_core.ipynb #f50bb04220c638a9 def noop_body(c, req): "Default Body wrap function which just returns the content" return c -# %% ../nbs/api/00_core.ipynb #7f49728d +# %% ../nbs/api/00_core.ipynb #d7ae82dfa40eb3f2 def respond(req, heads, bdy): "Default FT response creation function" body_wrap = getattr(req, 'body_wrap', noop_body) @@ -471,13 +489,13 @@ def respond(req, heads, bdy): body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw) return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw) -# %% ../nbs/api/00_core.ipynb #b2007479 +# %% ../nbs/api/00_core.ipynb #a3674bc161b39a23 def is_full_page(req, resp): "Check if response should be rendered as full page or fragment" if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers -# %% ../nbs/api/00_core.ipynb #358badec +# %% ../nbs/api/00_core.ipynb #3ee9a5fb4b7929a3 def _part_resp(req, resp): "Partition response into HTTP headers, background tasks, and content" resp = flat_tuple(resp) @@ -494,7 +512,7 @@ def _part_resp(req, resp): if len(resp)==1: resp = resp[0] return resp,kw -# %% ../nbs/api/00_core.ipynb #da449a7b +# %% ../nbs/api/00_core.ipynb #c69f2a6c99eeeb1e def _canonical(req): if not req.app.canonical: return [] url = str(getattr(req, 'canonical', req.url)).replace('http://', 'https://', 1) @@ -510,12 +528,12 @@ def _xt_cts(req, resp): resp = respond(req, [*heads, *title, *_canonical(req)], bdy) return _to_xml(req, resp, indent=fh_cfg.indent) -# %% ../nbs/api/00_core.ipynb #775fb66b +# %% ../nbs/api/00_core.ipynb #3651675357475aa2 def _is_ft_resp(resp): "Check if response is a FastTag-compatible type" return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__') -# %% ../nbs/api/00_core.ipynb #968d9245 +# %% ../nbs/api/00_core.ipynb #74898954bcd20abe def _resp(req, resp, cls=empty, status_code=200): "Create appropriate HTTP response from request and response data" if resp is None: resp='' @@ -535,7 +553,7 @@ def _resp(req, resp, cls=empty, status_code=200): cls = HTMLResponse return cls(resp, status_code=status_code, **kw) -# %% ../nbs/api/00_core.ipynb #eac44461 +# %% ../nbs/api/00_core.ipynb #4b0745e3157bccbb class Redirect: "Use HTMX or Starlette RedirectResponse as required to redirect to `loc`" def __init__(self, loc): self.loc = loc @@ -543,13 +561,13 @@ def __response__(self, req): if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc) return RedirectResponse(self.loc, status_code=303) -# %% ../nbs/api/00_core.ipynb #1ba52822 +# %% ../nbs/api/00_core.ipynb #147cb69ae182351c async def _wrap_call(f, req, params): "Wrap function call with request parameter injection" wreq = await _wrap_req(req, params) return await _handle(f, **wreq) -# %% ../nbs/api/00_core.ipynb #b0d1cbbf +# %% ../nbs/api/00_core.ipynb #f7f626a00aad8476 # chkstyle: skip htmx_exts = { "morph": "https://cdn.jsdelivr.net/npm/idiomorph@0.7.3/dist/idiomorph-ext.min.js", @@ -562,23 +580,23 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "ws4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-ws.min.js", + "ws4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-ws.min.js", "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", - "sse4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js", - "live": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js", - "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js" + "sse4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-sse.min.js", + "live": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-live.min.js", + "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js" } -# %% ../nbs/api/00_core.ipynb #60cb52ea +# %% ../nbs/api/00_core.ipynb #59076ca3b38a5fba htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") -htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js") -fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") +htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/htmx.min.js") +fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.13/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") viewport = Meta(name="viewport", content="width=device-width, initial-scale=1, viewport-fit=cover") charset = Meta(charset="utf-8") -# %% ../nbs/api/00_core.ipynb #8bd78eeb +# %% ../nbs/api/00_core.ipynb #3f9e66d883079f28 def get_key(key=None, fname='.sesskey'): "Get session key from `key` param or read/create from file `fname`" if key: return key @@ -588,12 +606,12 @@ def get_key(key=None, fname='.sesskey'): fname.write_text(key) return key -# %% ../nbs/api/00_core.ipynb #042666e2 +# %% ../nbs/api/00_core.ipynb #7eb35d0263b7da5a def _list(o): "Wrap non-list item in a list, returning empty list if None" return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o] -# %% ../nbs/api/00_core.ipynb #d276fc71 +# %% ../nbs/api/00_core.ipynb #87cbf1149a207d27 def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap): "Wrap exception handler with FastHTML request processing" async def _f(req, exc): @@ -603,7 +621,7 @@ async def _f(req, exc): return _resp(req, res, status_code=status_code) return _f -# %% ../nbs/api/00_core.ipynb #bc323fd4 +# %% ../nbs/api/00_core.ipynb #f772e4bd5cb60fe2 def qp(p:str, **kw) -> str: "Add parameters kw to path p" def _sub(m): @@ -615,12 +633,12 @@ def _sub(m): # encode query params return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '') -# %% ../nbs/api/00_core.ipynb #f86690c4 +# %% ../nbs/api/00_core.ipynb #7617757c40c72efe def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] if surreal: hdrs = [surrsrc,scopesrc] + hdrs - if htmx4: + if htmx and htmx4: # metaCharacter="-" makes htmx4 use dashes instead of colons meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"})) hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs @@ -628,7 +646,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs return [charset, viewport] + hdrs -# %% ../nbs/api/00_core.ipynb #2c5285ae +# %% ../nbs/api/00_core.ipynb #77869926479cdf46 cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*']) iframe_scr = Script(NotStr(""" @@ -654,7 +672,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): """)) -# %% ../nbs/api/00_core.ipynb #17ced9a3 +# %% ../nbs/api/00_core.ipynb #f127a504464b0eb5 class _LifespanCtx: def __init__(self, gen): self.gen = gen async def __aenter__(self): return await anext(self.gen) @@ -681,7 +699,7 @@ async def _run(self, app): def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f) -# %% ../nbs/api/00_core.ipynb #3327a1e9 +# %% ../nbs/api/00_core.ipynb #c186654600ae903f class FastHTML(Starlette): "An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments" def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None, @@ -691,7 +709,7 @@ def __init__(self, debug=False, routes=None, middleware=None, title: str = "Fast same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey', body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw): middleware,before,after = map(_list, (middleware,before,after)) - self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname + self.htmx4,self.title,self.canonical,self.session_cookie,self.key_fname = htmx4,title,canonical,session_cookie,key_fname hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts)) if htmx4 and exts: exts = ['ws4' if e in ('ws', 'ws4') else e for e in exts] exts = {k:htmx_exts[k] for k in exts} @@ -720,7 +738,7 @@ def _not_found(req, exc): return Response('404 Not Found', status_code=404) def on_event(self, event_type): return self.lifespan.on_event(event_type) -# %% ../nbs/api/00_core.ipynb #dce68049 +# %% ../nbs/api/00_core.ipynb #4d43cd70f3e6cd75 class HostRoute(Route): "Route with optional host-header constraint using Starlette's {param} pattern syntax" def __init__(self, path, endpoint, *, host=None, **kwargs): @@ -738,7 +756,7 @@ def __repr__(self) -> str: methods = sorted(self.methods or []) return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})" -# %% ../nbs/api/00_core.ipynb #e0accf76 +# %% ../nbs/api/00_core.ipynb #d100258e169dbbc7 @patch def add_route(self:FastHTML, route): "Add or replace a route in the FastHTML app" @@ -750,10 +768,10 @@ def add_route(self:FastHTML, route): self.router.routes.sort(key=lambda r: not getattr(r, 'host', None)) -# %% ../nbs/api/00_core.ipynb #246bd8d1 +# %% ../nbs/api/00_core.ipynb #d7af2cc5decee07 all_meths = 'get post put delete patch head trace options'.split() -# %% ../nbs/api/00_core.ipynb #26b147ba +# %% ../nbs/api/00_core.ipynb #e025b784db90b0f0 @patch def _endp(self:FastHTML, f, body_wrap, before:Optional[Callable|tuple]=None): "Create endpoint wrapper with before/after middleware processing" @@ -782,7 +800,7 @@ async def _f(req): return _resp(req, resp, sig.return_annotation) return _f -# %% ../nbs/api/00_core.ipynb #3818575c +# %% ../nbs/api/00_core.ipynb #3213bbd6e68c6eca @patch def _add_ws(self:FastHTML, func, path, conn, disconn, name, middleware): "Add websocket route to FastHTML app" @@ -792,20 +810,20 @@ def _add_ws(self:FastHTML, func, path, conn, disconn, name, middleware): self.add_route(route) return func -# %% ../nbs/api/00_core.ipynb #669e76eb +# %% ../nbs/api/00_core.ipynb #b04007ab3b0564b7 @patch def ws(self:FastHTML, path:str, conn=None, disconn=None, name=None, middleware=None): "Add a websocket route at `path`" def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware) return f -# %% ../nbs/api/00_core.ipynb #j6ete5u68fo +# %% ../nbs/api/00_core.ipynb #f327ebcd11de3159 @patch def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None): "Add a websocket route at `path` (Starlette-compatible API)" return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware) -# %% ../nbs/api/00_core.ipynb #919618c3 +# %% ../nbs/api/00_core.ipynb #73a6816c7fc2a48e def _mk_locfunc(f, p, app=None): "Create a location function wrapper with route path and to() method" class _lf: @@ -819,12 +837,12 @@ def __str__(self): return p return _lf() -# %% ../nbs/api/00_core.ipynb #d2ecc738 +# %% ../nbs/api/00_core.ipynb #106d9bbb8cb464d1 def nested_name(f): "Get name of function `f` using '_' to join nested function names" return f.__qualname__.replace('..', '_') -# %% ../nbs/api/00_core.ipynb #daafe4fc +# %% ../nbs/api/00_core.ipynb #bf1de7b1007ef27a @patch def _add_routes(self:FastHTML, cls, path, methods, name, include_in_schema, body_wrap, host=None, before:Optional[Callable|tuple]=None): "Add HTTP routes from methods on endpoint class `cls`" @@ -836,7 +854,7 @@ def _add_routes(self:FastHTML, cls, path, methods, name, include_in_schema, body if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before) return lf -# %% ../nbs/api/00_core.ipynb #3710e48b +# %% ../nbs/api/00_core.ipynb #2c1c95dde3ab364b def _route_pn(func, path, name): "Infer route name/function name/path from an endpoint or endpoint class" fn = nested_name(func) @@ -861,7 +879,7 @@ def _add_route(self:FastHTML, func, path, methods, name, include_in_schema, body lf.__routename__ = n return lf -# %% ../nbs/api/00_core.ipynb #f5cb2c2b +# %% ../nbs/api/00_core.ipynb #1c4c8d49e989e7c7 @patch def route(self:FastHTML, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=None, host=None, before:Optional[Callable|tuple]=None): "Add a route at `path`" @@ -871,14 +889,14 @@ def f(func): for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o)) -# %% ../nbs/api/00_core.ipynb #35c35a96 +# %% ../nbs/api/00_core.ipynb #bb214bd0b004e83c @patch def set_lifespan(self:FastHTML, value): "Set the lifespan context manager for the FastHTML app" if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value) self.router.lifespan_context = value -# %% ../nbs/api/00_core.ipynb #3a348474 +# %% ../nbs/api/00_core.ipynb #306333c1e9460062 def serve( appname=None, # Name of the module app='app', # App instance to be served @@ -904,14 +922,14 @@ def serve( print('Link: '+ S.light_red.bold(link)) run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs) -# %% ../nbs/api/00_core.ipynb #91b08e9a +# %% ../nbs/api/00_core.ipynb #30c5cb0a53a38c92 def _spawn(n): "Run this script `n` times over, numbering each copy in `FH_PROCNUM`, and wait for them all" ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)] signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps]) for p in ps: p.wait() -# %% ../nbs/api/00_core.ipynb #c5220a0e +# %% ../nbs/api/00_core.ipynb #157c183cd8bd0475 async def _wait_disconnect(req): while (await req.receive())["type"] != "http.disconnect": pass @@ -926,7 +944,7 @@ async def until_disconnect(req, coro): watch.cancel() if not work.done(): work.cancel() -# %% ../nbs/api/00_core.ipynb #c5721d76 +# %% ../nbs/api/00_core.ipynb #63f31cbcd3a2cb7e def cancel_on_disconnect( f=None, # Async route handler to wrap *, @@ -940,7 +958,7 @@ async def _f(__req: Request, *args, **kw): return await (asyncio.wait_for(c, timeout) if timeout is not None else c) return splice_sig(_f, f) -# %% ../nbs/api/00_core.ipynb #8121968a +# %% ../nbs/api/00_core.ipynb #778e281e884f69a4 class Client: "A simple httpx ASGI client that doesn't require `async`" def __init__(self, app, url="http://testserver"): @@ -953,7 +971,7 @@ async def _request(): return await self.cli.request(method, url, **kwargs) for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o)) -# %% ../nbs/api/00_core.ipynb #d5223a9a +# %% ../nbs/api/00_core.ipynb #f1c11644b46e01d1 class RouteFuncs: "Attr-access store of named route functions (HTTP verb names excluded)" def __init__(self): super().__setattr__('_funcs', {}) @@ -964,7 +982,7 @@ def __getattr__(self, name): except KeyError: raise AttributeError(f"No route named {name} found in route functions") def __dir__(self): return list(self._funcs.keys()) -# %% ../nbs/api/00_core.ipynb #0f08cb5e +# %% ../nbs/api/00_core.ipynb #890c6b36806499f6 class APIRouter: "Add routes to an app" def __init__(self, prefix:str|None=None, body_wrap=noop_body): @@ -1004,10 +1022,10 @@ def ws(self, path:str, conn=None, disconn=None, name=None, middleware=None): def f(func=noop): return self.wss.append((func, f"{self.prefix}{path}", conn, disconn, name, middleware)) return f -# %% ../nbs/api/00_core.ipynb #259a0f53 +# %% ../nbs/api/00_core.ipynb #c01860fe7517722d for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o)) -# %% ../nbs/api/00_core.ipynb #e518de83 +# %% ../nbs/api/00_core.ipynb #6d41906f21e2e5bb def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None, secure=False, httponly=False, samesite="lax",): "Create a 'set-cookie' `HttpHeader`" cookie = cookies.SimpleCookie() @@ -1024,13 +1042,13 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None cookie_val = cookie.output(header="").strip() return HttpHeader("set-cookie", cookie_val) -# %% ../nbs/api/00_core.ipynb #8816f277 +# %% ../nbs/api/00_core.ipynb #acfb97d2b978356 def reg_re_param(m, s): "Register a Starlette URL convertor named `m` matching regex `s`" cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s) register_url_convertor(m, cls()) -# %% ../nbs/api/00_core.ipynb #e9535978 +# %% ../nbs/api/00_core.ipynb #b5aea8aaa5b024d2 # Starlette doesn't have the '?', so it chomps the whole remaining URL reg_re_param("path", ".*?") _static_exts = "ico gif jpg jpeg webm css js woff png svg mp4 webp ttf otf eot woff2 txt html map pdf zip tgz gz csv mp3 wav ogg flac aac doc docx xls xlsx ppt pptx epub mobi bmp tiff avi mov wmv mkv xml yaml yml rar 7z tar bz2 htm xhtml apk dmg exe msi swf iso".split() @@ -1042,14 +1060,14 @@ def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static') @self.get(f"{prefix}{{fname:path}}.{{ext:{exts}}}") async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}') -# %% ../nbs/api/00_core.ipynb #b31de65a +# %% ../nbs/api/00_core.ipynb #179c165cf2d70fa0 @patch def static_route(self:FastHTML, ext='', prefix='/', static_path='.'): "Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')" @self.get(f"{prefix}{{fname:path}}{ext}") async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}') -# %% ../nbs/api/00_core.ipynb #f63b7a03 +# %% ../nbs/api/00_core.ipynb #df8649e9ff8a4862 class StaticNoCache(StaticFiles): "StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use" def file_response(self, *args, **kwargs): @@ -1057,7 +1075,7 @@ def file_response(self, *args, **kwargs): resp.headers.setdefault("Cache-Control", "no-cache") return resp -# %% ../nbs/api/00_core.ipynb #74801f94 +# %% ../nbs/api/00_core.ipynb #b4b419bffadedf7c class StaticImmutable(StaticFiles): "Like `StaticNoCache`, but marks responses as cacheable forever: for versioned URLs such as `vurl` links or content-hashed filenames" def file_response(self, *args, **kwargs): @@ -1065,7 +1083,7 @@ def file_response(self, *args, **kwargs): resp.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") return resp -# %% ../nbs/api/00_core.ipynb #e38fd6c7 +# %% ../nbs/api/00_core.ipynb #f650d7d18b386925 @flexicache(mtime_policy(arg=0)) def _vhash(p): return hashlib.blake2b(p.read_bytes(), digest_size=4).hexdigest() @@ -1077,11 +1095,11 @@ def vurl(path, root='.'): return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}')) -# %% ../nbs/api/00_core.ipynb #7189daf8 +# %% ../nbs/api/00_core.ipynb #e336fb23b6a4348 from functools import wraps from inspect import signature, isawaitable -# %% ../nbs/api/00_core.ipynb #7eed23b7 +# %% ../nbs/api/00_core.ipynb #fffed22d52dd5db8 def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty): "Add a parameter to a function's signature" sig = signature(f) @@ -1092,7 +1110,7 @@ def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Pa f.__annotations__[name] = typ return f -# %% ../nbs/api/00_core.ipynb #9e5a9e88 +# %% ../nbs/api/00_core.ipynb #2908db7385793cc5 class into: "Decorator to pass a route's return value into `func`, with keyword params added to the route signature" def __init__(self, func): @@ -1114,7 +1132,7 @@ async def _inner(*args, **kw): add_sig_param(_inner, k, anno, default=p.default) return _inner -# %% ../nbs/api/00_core.ipynb #1960d7ff +# %% ../nbs/api/00_core.ipynb #7f46d6e01ce2773c class MiddlewareBase: "Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`" async def __call__(self, scope, receive, send) -> None: @@ -1122,7 +1140,7 @@ async def __call__(self, scope, receive, send) -> None: await self._app(scope, receive, send) -# %% ../nbs/api/00_core.ipynb #83a20f93 +# %% ../nbs/api/00_core.ipynb #bd853c5a477ed976 class FtResponse: "Wrap an FT response with any Starlette `Response`" def __init__(self, content, status_code:int=200, headers=None, cls=HTMLResponse, media_type:str|None=None, background: BackgroundTask | None = None): @@ -1137,32 +1155,32 @@ def __response__(self, req): headers = {**(self.headers or {}), **httphdrs} return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks) -# %% ../nbs/api/00_core.ipynb #9dc1025e +# %% ../nbs/api/00_core.ipynb #78bc2834a3ea6cda def unqid(seeded=False): "Random unique id, base64-encoded and prefixed with '_' so it's usable as an HTML id" id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4() res = b64encode(id4.bytes) return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-')) -# %% ../nbs/api/00_core.ipynb #5b67e014 +# %% ../nbs/api/00_core.ipynb #7c977335a4ba289b def _add_ids(s): if not isinstance(s, FT): return if not getattr(s, 'id', None): s.id = unqid() for c in s.children: _add_ids(c) -# %% ../nbs/api/00_core.ipynb #1f590f25 +# %% ../nbs/api/00_core.ipynb #1a6b71aa1ba2e789 @patch def setup_ws(app:FastHTML, f=noop): conns = {} - async def on_connect(scope, send): conns[scope.client] = send - async def on_disconnect(scope): conns.pop(scope.client) + async def on_connect(ws, send): conns[ws.client] = send + async def on_disconnect(ws): conns.pop(ws.client) app.ws('/ws', conn=on_connect, disconn=on_disconnect)(f) async def send(s): for o in conns.values(): await o(s) app._send = send return send -# %% ../nbs/api/00_core.ipynb #a8a91edd +# %% ../nbs/api/00_core.ipynb #699ee6943e1c8ac6 devtools_loc = "/.well-known/appspecific/com.chrome.devtools.json" @patch @@ -1172,7 +1190,7 @@ def devtools_json(self:FastHTML, path=None, uuid=None): @self.route(devtools_loc) def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid)) -# %% ../nbs/api/00_core.ipynb #e27908c0 +# %% ../nbs/api/00_core.ipynb #75158856bbe1f5bf @patch def get_client(self:FastHTML, asink=False, **kw): "Get an httpx client with session cookes set from `**kw`" @@ -1184,7 +1202,7 @@ def get_client(self:FastHTML, asink=False, **kw): client.cookies.update({self.session_cookie: data.decode()}) return client -# %% ../nbs/api/00_core.ipynb #c845f437 +# %% ../nbs/api/00_core.ipynb #bb041161d82c7ec8 @patch def decode_session(self:FastHTML, cookie): "Decode a signed session cookie" @@ -1192,7 +1210,7 @@ def decode_session(self:FastHTML, cookie): unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None) return loads(b64decode(unsigned).decode()) -# %% ../nbs/api/00_core.ipynb #49260e9b +# %% ../nbs/api/00_core.ipynb #8e3efceea8c5b4e9 @patch def get_testclient(self:FastHTML, **kw): "Get a Starlette `TestClient` with session cookies set from `**kw`" diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 62cf20da..93af28cd 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1,25 +1,28 @@ { "cells": [ { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "#| default_exp core", - "id": "62225a49ec1f2684" + "id": "6f182d9b04f883f8", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp core" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "e95bb035875ca78e", + "metadata": {}, "source": [ "# Core\n", "> The `FastHTML` subclass of `Starlette`." - ], - "id": "1edf4b13531905e7" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "e677d55d4ab83fe3", + "metadata": {}, "source": [ "#| export\n", "Create an app with `app = FastHTML()`. The `@rt` decorator adds routes (`rt = app.route`), inferring what it can from the function:\n", @@ -40,7 +43,7 @@ "\n", "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", "\n", - "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data (use `Annotated[T, Alias('name')]` to read a different request name), and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", + "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. To read a different request name, use `Annotated[T, Alias('name')]`. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", "\n", " req, ws the Request / WebSocket connection\n", " sess session dict\n", @@ -61,26 +64,30 @@ "`before`/`after` functions (app-level, with `Beforeware` skip patterns, or per-route `before=`) get the same param injection as handlers. A `before` returning a value responds with it and skips the handler (the usual auth-guard pattern).\n", "\n", "See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above." - ], - "id": "20a5d1d37b1c0534" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/).", - "id": "b69e500bd089eb18" + "id": "903f740e786d72ef", + "metadata": {}, + "source": [ + "This is the source code to fasthtml. You won't need to read this unless you want to understand how things are built behind the scenes, or need full details of a particular API. The notebook is converted to the Python module [fasthtml/core.py](https://github.com/AnswerDotAI/fasthtml/blob/main/fasthtml/core.py) using [nbdev](https://nbdev.fast.ai/)." + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Imports and utils", - "id": "841096cebec26ecd" + "id": "54449bc2ec290078", + "metadata": {}, + "source": [ + "## Imports and utils" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c9ebcfc30a1f800", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess\n", @@ -109,14 +116,14 @@ "\n", "from fasthtml.starlette import *\n", "from python_multipart.exceptions import MultipartParseError" - ], - "id": "766f7b53b1b8fad9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3b3325d761ae38f0", + "metadata": {}, + "outputs": [], "source": [ "import time, tempfile\n", "\n", @@ -130,84 +137,92 @@ "from fastcore.test import *\n", "from fastcore.net import run_sync\n", "from starlette.datastructures import UploadFile" - ], - "id": "b17d8daec9d4f0ea" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "812ef9183ad0b657", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _params(f): return signature_ex(f, True).parameters\n", "\n", "empty = Parameter.empty" - ], - "id": "e63824c9a9c99d2a" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern.", - "id": "e638f0fba61e9334" + "id": "b3fe417531ab5791", + "metadata": {}, + "source": [ + "We write source code _first_, and then tests come _after_. The tests serve as both a means to confirm that the code works and also serves as working examples. The first exported function, `parsed_date`, is an example of this pattern." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c5028c09a628533", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def parsed_date(s:str):\n", " \"Convert `s` to a datetime\"\n", " return dtparse.parse(s)" - ], - "id": "154cd2aacf343e17" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "parsed_date('2pm')", - "id": "2758feb3c71a15cc" + "id": "99c729bebfde58a0", + "metadata": {}, + "outputs": [], + "source": [ + "parsed_date('2pm')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "isinstance(date.fromtimestamp(0), date)", - "id": "5779523e889512af" + "id": "26368c9d9e077c03", + "metadata": {}, + "outputs": [], + "source": [ + "isinstance(date.fromtimestamp(0), date)" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "160b6878e8a6a74d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def snake2hyphens(s:str):\n", " \"Convert `s` from snake case to hyphenated and capitalised\"\n", " s = snake2camel(s)\n", " return camel2words(s, '-')" - ], - "id": "114894324f4f3de6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "snake2hyphens(\"snake_case\")", - "id": "34e26a3028f8b6f3" + "id": "55b7ce87ad73f8fa", + "metadata": {}, + "outputs": [], + "source": [ + "snake2hyphens(\"snake_case\")" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "31fb5e7526f55d26", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "htmx_hdrs = dict(\n", @@ -239,14 +254,14 @@ "def _get_htmx(h):\n", " res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()}\n", " return HtmxHeaders(**res)" - ], - "id": "b5cf37e1b1e83204" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "51788957f6661b97", + "metadata": {}, + "outputs": [], "source": [ "def test_request(url: str='/', headers: dict=None, method: str='get')->Request:\n", " if headers is None: headers={}\n", @@ -254,53 +269,55 @@ " client=('127.0.0.1', 8000), server=('127.0.0.1', 8000))\n", " async def receive(): return dict(type=\"http.request\", body=b\"\", more_body=False)\n", " return Request(scope, receive)" - ], - "id": "7cbc3bc299e0f7d6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1e158eb13c8fd937", + "metadata": {}, + "outputs": [], "source": [ "h = test_request(headers=Headers({'HX-Request':'1'}))\n", "_get_htmx(h.headers)" - ], - "id": "c04a34a18145b957" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6a06097681bcd6a6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _mk_list(t, v): return [t(o) for o in listify(v)]" - ], - "id": "66c896da0bfefa83" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Request and response", - "id": "4e0d5c759fcbd3c2" + "id": "afbdcd4c35b7a02", + "metadata": {}, + "source": [ + "## Request and response" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "12f93b7aa5424194", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "fh_cfg = AttrDict(indent=True)" - ], - "id": "e174c8645b0457a3" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "58660171446e4348", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "_special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'}\n", @@ -309,14 +326,14 @@ " \"Check for common annotation issues; returns warning string or None\"\n", " if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f\"`{arg}` has no type annotation and is not a recognised special name, so is ignored.\"\n", " if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f\"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead).\"" - ], - "id": "ce5bb0bd514a1fc6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a6c4d5dec9a11382", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _fix_anno(t, o):\n", @@ -330,14 +347,14 @@ " if isinstance(t, type) and issubclass(t, (list,tuple)): return None\n", " if not isinstance(o, (str,list,tuple)): return o\n", " return res(o[-1]) if isinstance(o,(list,tuple)) else res(o)" - ], - "id": "f359709b8822b93c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1a9b7e8d12d23dda", + "metadata": {}, + "outputs": [], "source": [ "test_eq(_fix_anno(Union[str,None], 'a'), 'a')\n", "test_eq(_fix_anno(float, 0.9), 0.9)\n", @@ -351,14 +368,14 @@ "test_eq(_fix_anno(tuple, ['1','2']), None)\n", "test_eq(_fix_anno(list[str], []), [])\n", "test_eq(_fix_anno(Optional[list[str]], '1'), ['1'])" - ], - "id": "1f8f2a43982d41fe" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "10852e6f03bb7231", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _form_arg(k, v, d):\n", @@ -369,63 +386,65 @@ " anno = d.get(k, None)\n", " if not anno: return v\n", " return _fix_anno(anno, v)" - ], - "id": "2a3c54c664b362a8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "45caa12c914c6cff", + "metadata": {}, + "outputs": [], "source": [ "d = dict(k=int, l=List[int])\n", "test_eq(_form_arg('k', \"1\", d), 1)\n", "test_eq(_form_arg('l', \"1\", d), [1])\n", "test_eq(_form_arg('l', [\"1\",\"2\"], d), [1,2])" - ], - "id": "167225e773be8206" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b40f180dae734a89", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@dataclass\n", "class HttpHeader:\n", " \"A response header as a k,v pair\"\n", " k:str;v:str" - ], - "id": "15144ae89669849e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3cf407bbe6d72fce", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()\n", "\n", "htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,\n", " reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)" - ], - "id": "84a3dc1748f845d8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "_to_htmx_header('trigger_after_settle')", - "id": "cf91ff06890e82bd" + "id": "cee63b8f24b0c0a8", + "metadata": {}, + "outputs": [], + "source": [ + "_to_htmx_header('trigger_after_settle')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2cf50288f5a348f", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@use_kwargs_dict(**htmx_resps)\n", @@ -433,70 +452,72 @@ " \"HTMX response headers\"\n", " res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items())\n", " return res[0] if len(res)==1 else res" - ], - "id": "63cbc11b77a7373f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "HtmxResponseHeaders(trigger_after_settle='hi')", - "id": "45e23fb992603d24" + "id": "bc360792aa6c87a4", + "metadata": {}, + "outputs": [], + "source": [ + "HtmxResponseHeaders(trigger_after_settle='hi')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "922afe94af9a5100", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _annotations(anno):\n", " \"Same as `get_annotations`, but also works on namedtuples\"\n", " if is_namedtuple(anno): return {o:str for o in anno._fields}\n", " return get_annotations(anno)" - ], - "id": "4bc38ff0755a2c43" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "725b8e6b277f380", + "metadata": {}, + "outputs": [], "source": [ "@dataclass\n", "class Foo: a:int; b:str\n", "_annotations(Foo)" - ], - "id": "8818f6785ea4b2d7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2ce7a7675a658c1d", + "metadata": {}, + "outputs": [], "source": [ "Point = namedtuple('Point', ['x', 'y'])\n", "_annotations(Point)" - ], - "id": "d4bcd3236020690" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5a1fa453f1caafa7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno)" - ], - "id": "27c857509c0182ab" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "84fe8f035ba3132d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _formitem(form, k):\n", @@ -505,53 +526,53 @@ " if isinstance(form, dict): return form.get(k)\n", " o = form.getlist(k)\n", " return o[0] if len(o) == 1 else o if o else None" - ], - "id": "36117c4b0c779d27" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ba55a6ab2bf79e81", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def form2dict(form: FormData) -> dict:\n", " \"Convert starlette form data to a dict\"\n", " if isinstance(form, dict): return form\n", " return {k: _formitem(form, k) for k in form}" - ], - "id": "8b473215908c026d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1aece2ed47f3ede8", + "metadata": {}, + "outputs": [], "source": [ "d = [('a',1),('a',2),('b',0)]\n", "fd = FormData(d)\n", "res = form2dict(fd)\n", "test_eq(res['a'], [1,2])\n", "test_eq(res['b'], 0)" - ], - "id": "9e0a6dc2bbb2072e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7154b82a09979e0a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "DEF_MAXPART = 100*1024*1024" - ], - "id": "b1ddddcde3b83c93" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1f95924092f9c33d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def parse_form(req: Request) -> FormData:\n", @@ -571,48 +592,50 @@ " if not isinstance(res, dict): raise HTTPException(400, \"JSON body must be an object\")\n", " return res\n", " return await req.form(max_part_size=maxpart)" - ], - "id": "973ffa45a4d79eee" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Use the special `__from_request__` class method for custom creation:", - "id": "4e6df306be40bbc2" + "id": "9d81d7e43a0b2f5a", + "metadata": {}, + "source": [ + "Use the special `__from_request__` class method for custom creation:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c6711a4184912f33", + "metadata": {}, + "outputs": [], "source": [ "class Foo(BasicRepr):\n", " def __init__(self, d): self.d = d\n", "\n", " @classmethod\n", " def __from_request__(cls, data, req): return cls(data)" - ], - "id": "ed2adb6bdeea43c6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d96b2e3f51684700", + "metadata": {}, + "outputs": [], "source": [ "p = Parameter('a', Parameter.POSITIONAL_OR_KEYWORD, annotation=Foo)\n", "\n", "req = test_request(method='POST')\n", "async def mock_form(**kwargs): return FormData({'a':1})\n", "req.form = mock_form" - ], - "id": "c9dd716c48ee9b37" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8e8b9056b2f3a099", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _from_body(conn, p, data):\n", @@ -629,14 +652,14 @@ " if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data\n", " cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}\n", " return anno(**cargs)" - ], - "id": "caeeb8021b015e6a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a73dc9076461a6a9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class ApiReturn:\n", @@ -646,20 +669,22 @@ " def __init__(self, isapi=False): self.isapi = isapi\n", " def __call__(self, norm=None, **kw): return kw if self.isapi else norm\n", " def __bool__(self): return bool(self.isapi)" - ], - "id": "71eea63edd703329" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints.", - "id": "a96a235863cfa801" + "id": "8e96fdc9a5ccb8d3", + "metadata": {}, + "source": [ + "Routes often need to return different responses depending on whether the request comes from a browser or an API client. The `api:ApiReturn` parameter handles this automatically — add it to any route signature and call it with two arguments: the API response and the browser response. If the request's `Accept` header is `application/json` then a dict of the kwargs is returned, otherwise, the first value is returned. For example, `return api(Redirect('/records'), id=record.id)` returns {'id':id} for API callers and a redirect for the browser. This keeps your route logic in one place instead of splitting it across separate endpoints." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c835e81d6e4f5efd", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class JSONResponse(JSONResponseOrig):\n", @@ -668,14 +693,14 @@ " def _default(o): return list(o) if is_listy(o) else str(o)\n", " res = json.dumps(content, ensure_ascii=False, allow_nan=False, indent=None, separators=(\",\",\":\"), default=_default)\n", " return res.encode(\"utf-8\")\n" - ], - "id": "76fd012e7f06d6c4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "eda3a26f75c80269", + "metadata": {}, + "outputs": [], "source": [ "async def f(req):\n", " def _f(p:HttpHeader): ...\n", @@ -689,14 +714,14 @@ "d = dict(k='value1',v=['value2','value3'])\n", "response = client.post('/', data=d)\n", "print(response.json())" - ], - "id": "8f6b6d744235f447" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7a4287b3c6be8e7f", + "metadata": {}, + "outputs": [], "source": [ "# An empty `application/json` body yields `{}` (like the empty-multipart guard), not a 500\n", "async def _empty_json(req): return JSONResponse(form2dict(await parse_form(req)))\n", @@ -706,65 +731,69 @@ "test_eq(res.json(), {})\n", "res = client.post('/', json={'a':1}) # non-empty json still parses (behavior preserved)\n", "test_eq(res.json(), {'a':1})" - ], - "id": "462118d0a1a4f66b" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:", - "id": "5584c41a615aabde" + "id": "8a6253774ec8c5ae", + "metadata": {}, + "source": [ + "A malformed multipart body (here the opening boundary isn't followed by CRLF) is the client's error, so `parse_form` answers 400 rather than letting the parser's exception surface as a 500:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a7b9836c3b2a4631", + "metadata": {}, + "outputs": [], "source": [ "bad = b'--xyzXX\\r\\nContent-Disposition: form-data; name=\"a\"\\r\\n\\r\\n1\\r\\n--xyz--\\r\\n'\n", "res = client.post('/', content=bad, headers={'content-type':'multipart/form-data; boundary=xyz'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ], - "id": "21df476998395009" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:", - "id": "c20f8a892d3edc2a" + "id": "2560bc0f911905f", + "metadata": {}, + "source": [ + "A JSON body that does not decode, or decodes to something other than an object (an array, say), cannot supply route parameters, so it is refused with 400 rather than failing inside the form conversion:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1f3997722ada8861", + "metadata": {}, + "outputs": [], "source": [ "res = client.post('/', json=[1,2])\n", "test_eq(res.status_code, 400)\n", "res = client.post('/', content=b'not json', headers={'content-type':'application/json'})\n", "test_eq(res.status_code, 400)\n", "res.text" - ], - "id": "f4b885f06ed6448d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ad6e689226231537", + "metadata": {}, + "outputs": [], "source": [ "async def f(req): return Response(str(req.query_params.getlist('x')))\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n", "client.get('/?x=1&x=2').text" - ], - "id": "a93058bcf54da24b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4e1d265576d2d438", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@dataclass(frozen=True)\n", @@ -789,30 +818,30 @@ " if issubclass(anno, State): return conn.scope['app'].state\n", " if anno is dict: return data\n", " if _is_body(anno):\n", - " if 'session'.startswith(arg.lower()): return conn.scope.get('session', {})\n", + " if 'session'.startswith(key.lower()): return conn.scope.get('session', {})\n", " return await _from_body(conn, p, data)\n", - " if (msg := _check_anno(arg, anno)): return warn(msg)\n", + " if (msg := _check_anno(key, anno)): return warn(msg)\n", " # Special param names with no annotations\n", " if anno is empty:\n", - " if arg.lower()=='ws' or 'request'.startswith(arg.lower()): return conn\n", - " if 'session'.startswith(arg.lower()): return conn.scope.get('session', {})\n", - " if arg.lower()=='scope': return conn.scope\n", - " if arg.lower()=='data': return data\n", - " if arg.lower()=='htmx': return _get_htmx(hdrs)\n", - " if arg.lower()=='app': return conn.scope['app']\n", - " if arg.lower()=='state': return conn.scope['app'].state\n", - " if arg.lower()=='auth': return conn.scope.get('auth', None)\n", - " if arg.lower()=='send':\n", + " if key.lower()=='ws' or 'request'.startswith(key.lower()): return conn\n", + " if 'session'.startswith(key.lower()): return conn.scope.get('session', {})\n", + " if key.lower()=='scope': return conn.scope\n", + " if key.lower()=='data': return data\n", + " if key.lower()=='htmx': return _get_htmx(hdrs)\n", + " if key.lower()=='app': return conn.scope['app']\n", + " if key.lower()=='state': return conn.scope['app'].state\n", + " if key.lower()=='auth': return conn.scope.get('auth', None)\n", + " if key.lower()=='send':\n", " assert not isinstance(conn, Request), \"`send` requires a websocket, not a `Request`\"\n", " return partial(_send_ws, conn)\n", - " if arg.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json')\n", - " if arg.lower()=='body': return (await conn.body()).decode()\n", - " if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower())\n", + " if key.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json')\n", + " if key.lower()=='body': return (await conn.body()).decode()\n", + " if key.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, key.lower())\n", " return None\n", " # Not a special name or a special annotation\n", " res = conn.path_params.get(key, None)\n", " if res in (empty,None): res = conn.cookies.get(key, None)\n", - " if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key))\n", + " if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None)\n", " if res in (empty,None): res = _formitem(conn.query_params, key)\n", " if res in (empty,None): res = _formitem(data, key)\n", " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values`\n", @@ -825,51 +854,51 @@ " except ValueError as e:\n", " if isinstance(conn, Request): raise HTTPException(404, f\"{conn.url.path}: {e}\") from None\n", " raise" - ], - "id": "fe8d6fb3a21bfc08" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "333f3e9cee44b116", + "metadata": {}, + "outputs": [], "source": [ "data = form2dict(await parse_form(req))\n", "await _from_body(req, p, data)" - ], - "id": "651f224eda02bc8f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "40f862f356ae9ebb", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _find_ps(conn, data, hdrs, params):\n", " if conn.query_params: data |= dict(conn.query_params)\n", " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" - ], - "id": "c342b5ba562b350d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f88072f39efb683e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wrap_req(req, params):\n", " data = form2dict(await parse_form(req))\n", " return await _find_ps(req, data, req.headers, params)" - ], - "id": "84bf5afdd0d94dba" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "95095f35a5e490ba", + "metadata": {}, + "outputs": [], "source": [ "def g(test_val:int=0, item:HttpHeader=None, htmx:HtmxHeaders=None, api:ApiReturn=None): ...\n", "\n", @@ -882,14 +911,14 @@ "r = client.post('/', data=dict(test_val='21', k='a', v='b'), headers={'HX-Request': '1'})\n", "test_eq(r.status_code, 200)\n", "print(r.text)" - ], - "id": "20396c4110d87e07" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "344a41cd35813e62", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader, state): ...\n", "\n", @@ -900,14 +929,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ], - "id": "e209b6e5ff739895" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "21f4350b27bb8574", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str, b:HttpHeader): ...\n", "\n", @@ -918,14 +947,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/?a=1', data=d)\n", "print(response.text)" - ], - "id": "6a996814b84b8b6a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "44a9d5dfcd36e22e", + "metadata": {}, + "outputs": [], "source": [ "class Bar(BasicRepr):\n", " @classmethod\n", @@ -940,26 +969,30 @@ "\n", "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "client.post('/', headers={'Accept': 'application/json'}).text" - ], - "id": "307157d9853b1ed8" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "**Missing Request Params**", - "id": "d81764a19d62bae7" + "id": "656da34bb96217da", + "metadata": {}, + "source": [ + "**Missing Request Params**" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request.", - "id": "2bb2bb7b11dc989b" + "id": "56c21769fa8e07a2", + "metadata": {}, + "source": [ + "If a request param has a default value (e.g. `a:str=''`), the request is valid even if the user doesn't include the param in their request." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a6c0d46f27cd6a59", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str=''): ...\n", "\n", @@ -970,20 +1003,22 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ], - "id": "618408bc44ea081e" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`.", - "id": "dd41603d4db6a5e6" + "id": "be49f689111388d3", + "metadata": {}, + "source": [ + "If we remove the default value and re-run the request, we should get the following error `Missing required field: a`." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e2361d7c09c9dc0e", + "metadata": {}, + "outputs": [], "source": [ "def g(req, this:Starlette, a:str): ...\n", "\n", @@ -994,14 +1029,14 @@ "client = TestClient(Starlette(routes=[Route('/', f, methods=['POST'])]))\n", "response = client.post('/', json={}) # no param in request\n", "print(response.text)" - ], - "id": "4acfa6fdeca0bdcb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2891fffa291ffaa9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def flat_xt(lst):\n", @@ -1012,83 +1047,85 @@ " if isinstance(item, (list,tuple)): result.extend(item)\n", " else: result.append(item)\n", " return tuple(result)" - ], - "id": "b9ee2d9aa97f3567" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e346519dcdc20f65", + "metadata": {}, + "outputs": [], "source": [ "x = ft('a',1)\n", "test_eq(flat_xt([x, x, [x,x]]), (x,)*4)\n", "test_eq(flat_xt(x), (x,))" - ], - "id": "6e6d978b12ecec1a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7f97f41c7180179a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class Beforeware:\n", " \"Wrap a `before` function with `skip` path patterns that bypass it\"\n", " def __init__(self, f, skip=None): self.f,self.skip = f,skip or []\n", " def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'" - ], - "id": "ddf1e8cede32b6ba" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "24171b42d8ed41ee", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _handle(f, /, *args, **kwargs):\n", " return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)" - ], - "id": "cc5c6cdf9602dd50" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1960a31440d760ef", + "metadata": {}, + "outputs": [], "source": [ "async def _f(f): return f\"got {f}\"\n", "test_eq(await _handle(_f, f='hello'), 'got hello')" - ], - "id": "7c3c48b78747ac48" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Websockets / SSE", - "id": "84e9432fd3a4ab2b" + "id": "1a5aafad50bad413", + "metadata": {}, + "source": [ + "## Websockets / SSE" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "dc78b1c31eb67d83", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wrap_ws(ws, data, params):\n", " raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers`\n", " hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})\n", " return await _find_ps(ws, data, hdrs, params)" - ], - "id": "30af027af06ad5a7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "29df49dfbdbeab89", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _send_ws(ws, resp):\n", @@ -1117,14 +1154,14 @@ " if disconn: cls.on_disconnect = _disconnect\n", " cls.on_receive = _recv\n", " return cls" - ], - "id": "cbcf66eb4bf6d993" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "236148ae9145c814", + "metadata": {}, + "outputs": [], "source": [ "def on_receive(msg:str): return f\"Message text was: {msg}\"\n", "c = _ws_endp(on_receive)\n", @@ -1133,14 +1170,14 @@ " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!', data" - ], - "id": "47d1b8f409e41924" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "9ab2cb1c438fe4b9", + "metadata": {}, + "outputs": [], "source": [ "def on_receive(hx_trigger:str): return f\"trigger: {hx_trigger}\"\n", "cli = TestClient(Starlette(routes=[WebSocketRoute('/', _ws_endp(on_receive))]))\n", @@ -1148,27 +1185,27 @@ " ws.send_text(json.dumps({\"HEADERS\": {\"Hx-Trigger\": \"my-btn\"}}))\n", " data = ws.receive_text()\n", " assert data == 'trigger: my-btn', data\n" - ], - "id": "f655649f2c9b2a90" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "83fa302372e0e042", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def EventStream(s):\n", " \"Create a text/event-stream response from `s`\"\n", " return StreamingResponse(s, media_type=\"text/event-stream\")" - ], - "id": "94d9b06b3345639e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f588429f00c732e7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def signal_shutdown():\n", @@ -1181,58 +1218,60 @@ " self.force_exit = True\n", " self._orig_handle_exit(*args, **kwargs)\n", " return event" - ], - "id": "76536fa30a36fd84" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Routing and application", - "id": "f3251662d7ccbe86" + "id": "ddc4f27e7412a024", + "metadata": {}, + "source": [ + "## Routing and application" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1537735647b1227f", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def uri(_arg, **kwargs):\n", " \"Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`\"\n", " return f\"{quote(_arg)}/{urlencode(kwargs, doseq=True)}\"" - ], - "id": "1df38e4652418428" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3004b7d11797405c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def decode_uri(s):\n", " \"Decode a URI created by `uri()` back into argument and keyword dict\"\n", " arg,_,kw = s.partition('/')\n", " return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()}" - ], - "id": "912efff3d21d23b8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e9de9e161764947b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "from starlette.convertors import StringConvertor" - ], - "id": "7cf4345c88d621ac" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b9bb3ffe99344740", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "StringConvertor.regex = \"[^/]*\" # `+` replaced with `*`\n", @@ -1243,28 +1282,28 @@ " assert \"/\" not in value, \"May not contain path separators\"\n", " # assert value, \"Must not be empty\" # line removed due to errors\n", " return value" - ], - "id": "35645793ee2333d9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "cccf24a4868dd227", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", "def url_path_for(self:HTTPConnection, name: str, **path_params):\n", " lp = self.scope['app'].url_path_for(name, **path_params)\n", " return URLPath(f\"{self.scope['root_path']}{lp}\", lp.protocol, lp.host)" - ], - "id": "7069302c62fce8e8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "535fa019103e6f9d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "_verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href')\n", @@ -1291,25 +1330,25 @@ " \"Convert response to XML string with target URL resolution\"\n", " _find_targets(req, resp)\n", " return to_xml(resp, indent=indent)" - ], - "id": "ebab309b5bd1c979" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "69d2044af9aa17d4", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "_iter_typs = (tuple,list,map,filter,range,types.GeneratorType)" - ], - "id": "4442bbbe5651ae8e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "fecf15a81dec5af7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def flat_tuple(o):\n", @@ -1321,27 +1360,27 @@ " if isinstance(item, _iter_typs): result.extend(list(item))\n", " else: result.append(item)\n", " return tuple(result)" - ], - "id": "a13f07df3079ff3" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f50bb04220c638a9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def noop_body(c, req):\n", " \"Default Body wrap function which just returns the content\"\n", " return c" - ], - "id": "8f7f3a3405e1e697" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d7ae82dfa40eb3f2", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def respond(req, heads, bdy):\n", @@ -1351,37 +1390,37 @@ " bw_args = (bdy, req) if len(params)>1 else (bdy,)\n", " body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw)\n", " return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw)" - ], - "id": "8266da60e013daa5" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "b588b0f6c2e18bc6", + "metadata": {}, "source": [ "Render fragment if `HX-Request` header is _present_ and\n", " `HX-History-Restore-Request` header is _absent._" - ], - "id": "53b1bc8f6257741d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a3674bc161b39a23", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def is_full_page(req, resp):\n", " \"Check if response should be rendered as full page or fragment\"\n", " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" - ], - "id": "2c2152165492b8c9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3ee9a5fb4b7929a3", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _part_resp(req, resp):\n", @@ -1399,14 +1438,14 @@ " resp = tuple(resp)\n", " if len(resp)==1: resp = resp[0]\n", " return resp,kw" - ], - "id": "f151183b32687f68" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c69f2a6c99eeeb1e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _canonical(req):\n", @@ -1423,27 +1462,27 @@ " title = [] if any(getattr(o, 'tag', '')=='title' for o in heads) else [Title(req.app.title)]\n", " resp = respond(req, [*heads, *title, *_canonical(req)], bdy)\n", " return _to_xml(req, resp, indent=fh_cfg.indent)" - ], - "id": "2ac4e8b2587860d1" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3651675357475aa2", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _is_ft_resp(resp):\n", " \"Check if response is a FastTag-compatible type\"\n", " return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')" - ], - "id": "e6934cf43a725446" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "74898954bcd20abe", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _resp(req, resp, cls=empty, status_code=200):\n", @@ -1464,14 +1503,14 @@ " resp = str(resp)\n", " cls = HTMLResponse\n", " return cls(resp, status_code=status_code, **kw)" - ], - "id": "2f67ee9bffca1af0" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4b0745e3157bccbb", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class Redirect:\n", @@ -1480,28 +1519,28 @@ " def __response__(self, req):\n", " if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc)\n", " return RedirectResponse(self.loc, status_code=303)" - ], - "id": "dba9d4e6f6485582" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "147cb69ae182351c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wrap_call(f, req, params):\n", " \"Wrap function call with request parameter injection\"\n", " wreq = await _wrap_req(req, params)\n", " return await _handle(f, **wreq)" - ], - "id": "2c57c9e3b3080bf6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f7f626a00aad8476", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "# chkstyle: skip\n", @@ -1522,28 +1561,32 @@ " \"live\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-live.min.js\",\n", " \"multipart\": \"https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js\"\n", "}" - ], - "id": "ce19cf0e812f4eed" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "The FastHTML `exts` param supports the following:", - "id": "dd98be8810a6ceee" + "id": "197c9278e1f04125", + "metadata": {}, + "source": [ + "The FastHTML `exts` param supports the following:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "print(' '.join(htmx_exts))", - "id": "f17d31038131da6f" + "id": "a9248bc09da25c47", + "metadata": {}, + "outputs": [], + "source": [ + "print(' '.join(htmx_exts))" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "59076ca3b38a5fba", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", @@ -1553,14 +1596,14 @@ "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", "viewport = Meta(name=\"viewport\", content=\"width=device-width, initial-scale=1, viewport-fit=cover\")\n", "charset = Meta(charset=\"utf-8\")" - ], - "id": "57564d874a5020f6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3f9e66d883079f28", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def get_key(key=None, fname='.sesskey'):\n", @@ -1571,27 +1614,27 @@ " key = str(uuid.uuid4())\n", " fname.write_text(key)\n", " return key" - ], - "id": "734e1a07d2bbcb10" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7eb35d0263b7da5a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _list(o):\n", " \"Wrap non-list item in a list, returning empty list if None\"\n", " return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o]" - ], - "id": "53259808c83e5169" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "87cbf1149a207d27", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap):\n", @@ -1602,14 +1645,14 @@ " res = await _handle(f, req, exc)\n", " return _resp(req, res, status_code=status_code)\n", " return _f" - ], - "id": "89a5250eb2a3a719" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f772e4bd5cb60fe2", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def qp(p:str, **kw) -> str:\n", @@ -1622,57 +1665,63 @@ " p = re.sub(r'\\{([^:}]+)(:.+?)?}', _sub, p)\n", " # encode query params\n", " return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')" - ], - "id": "fc5507023074e955" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`qp` adds query parameters to route path strings", - "id": "f862919a89f4caa" + "id": "9e0a824ad9d735f4", + "metadata": {}, + "source": [ + "`qp` adds query parameters to route path strings" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)", - "id": "d3e99daf5c14924e" + "id": "491e70657d582c45", + "metadata": {}, + "outputs": [], + "source": [ + "vals = dict(a=5, b=False, c=[1,2], d='bar', e=None, ab=42)" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bc4d62260c1ac7a9", + "metadata": {}, + "outputs": [], "source": [ "res = qp('/foo', **vals)\n", "test_eq(res, '/foo?a=5&b=&c=1&c=2&d=bar&e=&ab=42')" - ], - "id": "b9af73fa45922b9d" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly.", - "id": "d7d70e650e9becb0" + "id": "388f78c32327ad5e", + "metadata": {}, + "source": [ + "`qp` checks to see if each param should be sent as a query parameter or as part of the route, and encodes that properly." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b005f90f77d725ea", + "metadata": {}, + "outputs": [], "source": [ "path = '/foo/{a}/{d}/{ab:int}'\n", "res = qp(path, **vals)\n", "test_eq(res, '/foo/5/bar/42?b=&c=1&c=2&e=')" - ], - "id": "7197456ec5431ffc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7617757c40c72efe", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def def_hdrs(htmx=True, htmx4=False, surreal=True):\n", @@ -1686,14 +1735,14 @@ " # TODO: Check if fhjsscr works with htmx4\n", " elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" - ], - "id": "6d573895895b93de" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "77869926479cdf46", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])\n", @@ -1719,14 +1768,14 @@ " }\n", " };\n", "\"\"\"))\n" - ], - "id": "380c8297ab1fdf13" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f127a504464b0eb5", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class _LifespanCtx:\n", @@ -1754,14 +1803,14 @@ " for f in self.shutdown: await _handle(f)\n", "\n", " def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)" - ], - "id": "9361b5e43a27fd11" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c186654600ae903f", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class FastHTML(Starlette):\n", @@ -1801,20 +1850,22 @@ " super().__init__(debug, routes, middleware=middleware, exception_handlers=excs, lifespan=self.lifespan)\n", "\n", " def on_event(self, event_type): return self.lifespan.on_event(event_type)" - ], - "id": "28c9653b6c0a14af" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "### Routes", - "id": "865296f3f1f20553" + "id": "8bd8ad987e8e02ed", + "metadata": {}, + "source": [ + "### Routes" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4d43cd70f3e6cd75", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class HostRoute(Route):\n", @@ -1833,14 +1884,14 @@ " def __repr__(self) -> str:\n", " methods = sorted(self.methods or [])\n", " return f\"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})\"" - ], - "id": "4f15c16542b89f8" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b14a50661eb6c393", + "metadata": {}, + "outputs": [], "source": [ "app = FastHTML()\n", "app.router.routes = []\n", @@ -1853,14 +1904,14 @@ "test_eq(cli.get('/', headers={'host': 'localhost'}).text, 'main page')\n", "test_eq(cli.get('/', headers={'host': 'auth.example.com'}).text, 'auth page')\n", "print('all passed')" - ], - "id": "efd9140ff39fe5c4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6401176b1ba7ca40", + "metadata": {}, + "outputs": [], "source": [ "app.router.routes.append(HostRoute('/hello', lambda _: Response('api hello'), host='api.{parentdom}', methods=['GET'], name='api_hello'))\n", "app.router.routes.append(HostRoute('/hello', lambda _: Response('normal hello'), methods=['GET'], name='hello'))\n", @@ -1869,14 +1920,14 @@ "test_eq(cli.get('/hello', headers={'host': 'mysite.com'}).text, 'normal hello')\n", "test_eq(cli.get('/hello', headers={'host': 'other.mysite.com'}).text, 'normal hello')\n", "print('all passed')" - ], - "id": "257f7e4981d02717" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d100258e169dbbc7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1888,25 +1939,25 @@ " ((route.methods is None) or (set(r.methods) == set(route.methods))))]\n", " self.router.routes.append(route)\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" - ], - "id": "caffc08b07c8bcb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d7af2cc5decee07", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "all_meths = 'get post put delete patch head trace options'.split()" - ], - "id": "e9f4f950ae9801d6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e025b784db90b0f0", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1936,14 +1987,14 @@ " if nr: resp = nr\n", " return _resp(req, resp, sig.return_annotation)\n", " return _f" - ], - "id": "35dc94a31e586f2f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3213bbd6e68c6eca", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1954,14 +2005,14 @@ " route.methods = ['ws']\n", " self.add_route(route)\n", " return func" - ], - "id": "6f42e9795ed9d244" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b04007ab3b0564b7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -1969,28 +2020,28 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)\n", " return f" - ], - "id": "de7fe08284137add" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f327ebcd11de3159", + "metadata": {}, + "outputs": [], "source": [ "#|export\n", "@patch\n", "def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None):\n", " \"Add a websocket route at `path` (Starlette-compatible API)\"\n", " return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware)" - ], - "id": "87187e749e5e931e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "73a6816c7fc2a48e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _mk_locfunc(f, p, app=None):\n", @@ -2005,50 +2056,50 @@ " def __str__(self): return p\n", "\n", " return _lf()" - ], - "id": "33d86a55c9b92f78" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "106d9bbb8cb464d1", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def nested_name(f):\n", " \"Get name of function `f` using '_' to join nested function names\"\n", " return f.__qualname__.replace('..', '_')" - ], - "id": "35c6706df3663277" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "587180d55e32852b", + "metadata": {}, + "outputs": [], "source": [ "def f():\n", " def g(): ...\n", " return g" - ], - "id": "59602d676eadf6ad" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "91c5715a38fc35ed", + "metadata": {}, + "outputs": [], "source": [ "func = f()\n", "nested_name(func)" - ], - "id": "8d0508427acea4f7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bf1de7b1007ef27a", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -2061,14 +2112,14 @@ " handler = getattr(cls, meth, None)\n", " if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before)\n", " return lf" - ], - "id": "5e57c9b359478ec4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2c1c95dde3ab364b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _route_pn(func, path, name):\n", @@ -2094,14 +2145,14 @@ " lf = _mk_locfunc(func, p, app=self)\n", " lf.__routename__ = n\n", " return lf" - ], - "id": "958685a59c1adc37" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1c4c8d49e989e7c7", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -2112,47 +2163,49 @@ " return f(path) if callable(path) else f\n", "\n", "for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o))" - ], - "id": "6d07f10ad2ee837c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": [ + "id": "a8a5d347ea952b15", + "metadata": {}, + "outputs": [], + "source": [ "app = FastHTML()\n", "@app.get\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ], - "id": "3d2d35aafa07d69e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5f4225afe09d32b2", + "metadata": {}, + "outputs": [], "source": [ "@app.get('/foo/{a}')\n", "def foo(a:str, b:list[int]): ...\n", "\n", "foo.to(a='bar', b=[1,2])" - ], - "id": "1bbd2e215906320a" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "### Utils", - "id": "1c55d966dad7b5ec" + "id": "30bd204b9ae3eb48", + "metadata": {}, + "source": [ + "### Utils" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bb214bd0b004e83c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -2160,14 +2213,14 @@ " \"Set the lifespan context manager for the FastHTML app\"\n", " if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value)\n", " self.router.lifespan_context = value" - ], - "id": "2871dd3926e94cce" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "306333c1e9460062", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def serve(\n", @@ -2194,20 +2247,22 @@ " link = f'http://{\"localhost\" if host==\"0.0.0.0\" else host}:{port}'\n", " print('Link: '+ S.light_red.bold(link))\n", " run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)" - ], - "id": "f7dd4fc84b21b1d5" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them.", - "id": "f20cb7f71dea4782" + "id": "b0c79bd2f818722b", + "metadata": {}, + "source": [ + "`procs` runs the app as that many processes on consecutive ports from `port`, each with its own reload supervisor, and defaults to the `PROCS` environment variable. `serve` re-executes the script once per process with `FH_PROCNUM` set to its number, which is also the guard against a child spawning again. An app reads `FH_PROCNUM` for work that only one process should do, such as a background job: it is `'0'` in the first process, and unset in an ordinary single-process run. A reverse proxy in front of the ports decides how requests reach them." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "30c5cb0a53a38c92", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _spawn(n):\n", @@ -2215,20 +2270,22 @@ " ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)]\n", " signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])\n", " for p in ps: p.wait()" - ], - "id": "f34f9b5ca6567162" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first.", - "id": "be29597fe5b17234" + "id": "868be1a219c3dc25", + "metadata": {}, + "source": [ + "Starlette does not cancel a running handler when a client disconnects: the disconnect is only visible as an `http.disconnect` message on the ASGI receive channel, which ordinary handlers never read. `until_disconnect` races a coroutine against that message, cancelling the work if the client goes away first." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "157c183cd8bd0475", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "async def _wait_disconnect(req):\n", @@ -2244,20 +2301,22 @@ " finally:\n", " watch.cancel()\n", " if not work.done(): work.cancel()" - ], - "id": "2cb43965c7a83d32" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled.", - "id": "395800c38312e62e" + "id": "738c7a7a629c8769", + "metadata": {}, + "source": [ + "`cancel_on_disconnect` applies this to a route handler. `splice_sig` adds a private `Request`-annotated parameter to the exposed signature, which FastHTML fills with the connection, so the handler's own parameters are unchanged. It requires an async handler, since a sync handler runs in a threadpool and cannot be cancelled." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "63f31cbcd3a2cb7e", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def cancel_on_disconnect(\n", @@ -2272,14 +2331,14 @@ " c = until_disconnect(__req, f(**kw))\n", " return await (asyncio.wait_for(c, timeout) if timeout is not None else c)\n", " return splice_sig(_f, f)" - ], - "id": "a5ce96916bc04402" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "778e281e884f69a4", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class Client:\n", @@ -2293,55 +2352,63 @@ " with from_thread.start_blocking_portal() as portal: return portal.call(_request)\n", "\n", "for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))" - ], - "id": "83d324009fdb6f1e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "43bffe7939784946", + "metadata": {}, + "outputs": [], "source": [ "app = FastHTML(routes=[Route('/', lambda _: Response('test'))])\n", "cli = Client(app)\n", "\n", "cli.get('/').text" - ], - "id": "f7efbbe1ce446de9" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable.", - "id": "285cc520072871ae" + "id": "d9cee9e6977a4004", + "metadata": {}, + "source": [ + "Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable." + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## FastHTML Tests", - "id": "d3946db9903c0c31" + "id": "b3c16a868b4815b", + "metadata": {}, + "source": [ + "## FastHTML Tests" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "def get_cli(app): return app,TestClient(app),app.route", - "id": "97f6b629764da311" + "id": "2e90c46ddaaf39a4", + "metadata": {}, + "outputs": [], + "source": [ + "def get_cli(app): return app,TestClient(app),app.route" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "2db2d45897f8c287" + "id": "6d6166efda69fd05", + "metadata": {}, + "outputs": [], + "source": [ + "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c5b442f6f5d56b15", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML(title=\"My Custom Title\"))\n", "@app.get\n", @@ -2353,14 +2420,14 @@ "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ], - "id": "a13dde8eb8b6b16c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7e36f21c33ef4204", + "metadata": {}, + "outputs": [], "source": [ "@rt(host='test.foo')\n", "def foo(): return Div(\"Goodbye World\")\n", @@ -2369,27 +2436,27 @@ "\n", "assert \"Goodbye World\" in cli.get('/foo', headers={'host': 'test.foo'}).text\n", "assert \"Hello World\" in cli.get('/foo').text" - ], - "id": "f85d688757db4d7b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "48891dbe78e2901e", + "metadata": {}, + "outputs": [], "source": [ "response = cli.get('/foo')\n", "assert 'My Custom Title' in response.text\n", "\n", "foo.to(param='value')" - ], - "id": "61d6fe1d2dbd5a25" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3861d6a6d2e1877d", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -2398,26 +2465,26 @@ "\n", "txt = cli.get('/xt2').text\n", "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt" - ], - "id": "7209021af6a2190e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2c737e80962d632a", + "metadata": {}, + "outputs": [], "source": [ "def test_r(cli, path, exp, meth='get', hx=False, **kwargs):\n", " if hx: kwargs['headers'] = {'hx-request':\"1\"}\n", " test_eq(getattr(cli, meth)(path, **kwargs).text, exp)" - ], - "id": "fd987bab1066e1c9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c70488e0bcf9f502", + "metadata": {}, + "outputs": [], "source": [ "@dataclass\n", "class Card: id:str; title:str\n", @@ -2432,14 +2499,14 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ], - "id": "b9313ba355e1fb06" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "36c96619662118d7", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "class Things:\n", @@ -2447,54 +2514,54 @@ "\n", "test_eq(cli.get('/things?id=7').text, 'thing 7')\n", "test_eq(Things.to(id=7), '/things?id=7')" - ], - "id": "283c28edbb3b407a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e1bc26d746c79810", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/hi\")\n", "def get(): return 'Hi there'\n", "\n", "r = cli.get('/hi')\n", "r.text" - ], - "id": "a5fa0258070685cc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2357dbfe20b61390", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/hi\")\n", "def post(): return 'Postal'\n", "\n", "cli.post('/hi').text" - ], - "id": "24ae998024bb0e9b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c8f3da32e20a2d24", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/hostie\")\n", "def show_host(req): return req.headers['host']\n", "\n", "cli.get('/hostie').text" - ], - "id": "f89bce612cf6b114" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2c29857acc9e9818", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/setsess\")\n", "def set_sess(session):\n", @@ -2510,100 +2577,100 @@ " data = ws.receive_text()\n", "assert 'Message text was: Hi! with session bar' in data\n", "print(data)" - ], - "id": "1852e7ae89c3ebd" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "84e9f14b0cbe4741", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def yoyo(): return 'a yoyo'\n", "\n", "cli.post('/yoyo').text" - ], - "id": "df7dfb3c8dc63ecc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "44909d86b4f628e1", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autopost(): return Html(Div('Text.', hx_post=yoyo()))\n", "print(cli.get('/autopost').text)" - ], - "id": "e21e967ce097bf10" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8599023efac0d0ea", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autopost2(): return Html(Body(Div('Text.', cls='px-2', hx_post=show_host.to(a='b'))))\n", "print(cli.get('/autopost2').text)" - ], - "id": "a0efb7dc288753fc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "439b66d0144f8ed1", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autoget2(): return Html(Div('Text.', hx_get=show_host))\n", "print(cli.get('/autoget2').text)" - ], - "id": "f0e3b533ed6f45c7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "20571bea2a9a66d", + "metadata": {}, + "outputs": [], "source": [ "@rt('/user/{nm}', name='gday')\n", "def get(nm:str=''): return f\"Good day to you, {nm}!\"\n", "cli.get('/user/Alexis').text" - ], - "id": "b1d218f86454edcd" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "69a9fd107705ed47", + "metadata": {}, + "outputs": [], "source": [ "@app.get\n", "def autolink(): return Html(Div('Text.', link=uri('gday', nm='Alexis')))\n", "print(cli.get('/autolink').text)" - ], - "id": "10cf11188506e731" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "41d305a1aa78fb54", + "metadata": {}, + "outputs": [], "source": [ "@rt('/link')\n", "def get(req): return f\"{req.url_for('gday', nm='Alexis')}; {req.url_for('show_host')}\"\n", "\n", "cli.get('/link').text" - ], - "id": "6e5197fe7b759fb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8bb661471cb2ca13", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/background\")\n", "async def background_task(request):\n", @@ -2613,22 +2680,24 @@ " return P(\"Task started\"), BackgroundTask(long_running_task)\n", "\n", "response = cli.get(\"/background\")" - ], - "id": "a411d585d37c6b20" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')", - "id": "f364cb1a04a03aca" + "id": "38c44ff35db35b25", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(app.router.url_path_for('gday', nm='Jeremy'), '/user/Jeremy')" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c574a6639678d314", + "metadata": {}, + "outputs": [], "source": [ "hxhdr = {'headers':{'hx-request':\"1\"}}\n", "\n", @@ -2651,62 +2720,62 @@ "\n", "txt = cli.get('/xt3').text\n", "assert 'FastHTML page' not in txt and 'hi' in txt and '

there

' in txt" - ], - "id": "d612a4818affa3b9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "27219506256e3698", + "metadata": {}, + "outputs": [], "source": [ "# declaration-time warnings\n", "def _noanno(nope): return nope\n", "def _bare(names:list): return str(names)\n", "test_warns(lambda: rt('/oops')(_noanno))\n", "test_warns(lambda: rt('/barelist')(_bare))" - ], - "id": "7a172c72f3f8e612" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "18a82ab866caacc0", + "metadata": {}, + "outputs": [], "source": [ "# runtime warnings\n", "test_warns(lambda: cli.get('/oops?nope=1'))\n", "test_warns(lambda: cli.get('/barelist?names=1'))" - ], - "id": "7b25f9ca907e9f5" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "46d13c3989713a0", + "metadata": {}, + "outputs": [], "source": [ "ModelName = str_enum('ModelName', \"alexnet\", \"resnet\", \"lenet\")\n", "fake_db = [{\"name\": \"Foo\"}, {\"name\": \"Bar\"}]" - ], - "id": "319cd06919776162" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "68b9a9c53831c6e4", + "metadata": {}, + "outputs": [], "source": [ "@rt('/html/{idx}')\n", "async def get(idx:int): return Body(H4(f'Next is {idx+1}.'))" - ], - "id": "1be7b1965340a047" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8088e5034676f071", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/models/{nm}\")\n", "def get(nm:ModelName): return nm\n", @@ -2725,14 +2794,14 @@ "\n", "@rt(\"/unionidxl/\")\n", "def get(idx:list[int] | None): return str(idx)" - ], - "id": "f4c24fb6058a6e54" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a00df0aa1ccce1d1", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/html/1', headers={'hx-request':\"1\"})\n", "assert '

Next is 2.

' in r.text\n", @@ -2746,14 +2815,14 @@ "assert cli.get('/idxl/?idx=1&idx=g').status_code == 404\n", "test_r(cli, '/optidxl/?idx=1&idx=2', '[1, 2]')\n", "test_r(cli, '/unionidxl/?idx=1&idx=2', '[1, 2]')" - ], - "id": "9d6412d097761778" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "cf476961f5b1d0c6", + "metadata": {}, + "outputs": [], "source": [ "app = FastHTML()\n", "rt = app.route\n", @@ -2763,30 +2832,34 @@ "@app.route(r'/static/{path:path}')\n", "def foo(path:str, a:int): return f'also got {path},{a}'\n", "cli.get('/static/sub/a.b.jpg').text" - ], - "id": "533da48ca6996e4d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "cli.get('/static/sub/a.b?a=1').text", - "id": "679493fc6e010dcb" + "id": "4bbc51e39895b2c2", + "metadata": {}, + "outputs": [], + "source": [ + "cli.get('/static/sub/a.b?a=1').text" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "app.chk = 'foo'", - "id": "25947574fb51212f" + "id": "c1c7645739c9eecb", + "metadata": {}, + "outputs": [], + "source": [ + "app.chk = 'foo'" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "48114b31ea9e71b0", + "metadata": {}, + "outputs": [], "source": [ "@app.get(\"/booly/\")\n", "def _(coming:bool=True): return 'Coming' if coming else 'Not coming'\n", @@ -2817,14 +2890,14 @@ "\n", "@app.get(\"/app4\")\n", "def _(foo:FastHTML): return Redirect(\"http://example.org\")" - ], - "id": "14d43f169641ddb6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8cdcda801e94956f", + "metadata": {}, + "outputs": [], "source": [ "test_r(cli, '/booly/?coming=true', 'Coming')\n", "test_r(cli, '/booly/?coming=no', 'Not coming')\n", @@ -2839,59 +2912,59 @@ " ({'HX-Prompt':'Hi%20%C3%A9+%2520', 'HX-Request-Type':'partial'}, 'Hi é+%20'),\n", " ({'HX-Prompt':'Hi%20%C3%A9%2B%2520', 'HX-Prompt-URI-AutoEncoded':'true'}, 'Hi é+%20')]:\n", " test_eq(cli.get('/hxprompt', headers=headers).json(), [expected, expected])" - ], - "id": "e270772d70e64738" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ec6a3836a419e25d", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app2', **hxhdr)\n", "test_eq(r.text, 'foo')\n", "test_eq(r.headers['mykey'], 'myval')" - ], - "id": "7fa38760a2c4c2b9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a553403903b5cae9", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app3')\n", "test_eq(r.headers['HX-Location'], 'http://example.org')" - ], - "id": "bff4f4ad08765cb0" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "459c74dfd6a88eae", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app4', follow_redirects=False)\n", "test_eq(r.status_code, 303)" - ], - "id": "5e28d8c24a7c39d9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "a7b198137f231c07", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/app4', headers={'HX-Request':'1'})\n", "test_eq(r.headers['HX-Redirect'], 'http://example.org')" - ], - "id": "124d00997ca5442" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "da0c027a530c6148", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def meta(): return ((Title('hi'), H1('hi')), (Meta(property='image'), Meta(property='site_name')))\n", @@ -2899,14 +2972,14 @@ "t = cli.post('/meta').text\n", "assert re.search(r'\\s*

hi

\\s*', t)\n", "assert 'It worked!\" in response and \"

15, Lorem

\" in response" - ], - "id": "7d153de0eeb79b58" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "93da225917fd0ebb", + "metadata": {}, + "outputs": [], "source": [ "# Testing POST with Content-Type: application/json\n", "@app.post(\"/bodytext\")\n", @@ -3027,22 +3102,24 @@ "\n", "response = cli.post('/bodytext', headers={\"Content-Type\": \"application/json\"}, data=s).text\n", "test_eq(response, '{\"b\": \"Lorem\", \"a\": 15}')" - ], - "id": "7af9ca8355488281" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]", - "id": "a3d393106d3e9ba7" + "id": "718c7f272bb77375", + "metadata": {}, + "outputs": [], + "source": [ + "files = [('files', ('file1.txt', b'content1')), ('files', ('file2.txt', b'content2'))]" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "78f9c8b7cc4a6f8c", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/uploads\")\n", "async def post(files:list[UploadFile]): return ','.join([(await file.read()).decode() for file in files])\n", @@ -3050,26 +3127,26 @@ "res = cli.post('/uploads', files=files)\n", "print(res.status_code)\n", "print(res.text)" - ], - "id": "c64599ca56c426a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "33a842b973382258", + "metadata": {}, + "outputs": [], "source": [ "res = cli.post('/uploads', files=[files[0]])\n", "test_eq(res.status_code, 200)\n", "test_eq(res.text, 'content1')" - ], - "id": "41a1daf7b482f070" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "fa5e2a257fd14615", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/setsess\")\n", "def get(sess, foo:str=''):\n", @@ -3084,14 +3161,14 @@ "time.sleep(0.01)\n", "\n", "cli.get('/getsess').text" - ], - "id": "92675c4d030a3259" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "94b63c69eeb88e7c", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/sess-first\")\n", "def post(sess, name: str):\n", @@ -3104,59 +3181,61 @@ "def get(sess): return sess['name']\n", "\n", "test_eq(cli.get('/getsess-all').text, '2')" - ], - "id": "c7056c6628637864" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8843e1a22d29d1c3", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/upload\")\n", "async def post(uf:UploadFile): return (await uf.read()).decode()\n", "\n", "with open('../../CHANGELOG.md', 'rb') as f: print(cli.post('/upload', files={'uf':f}, data={'msg':'Hello'}).text[:15])" - ], - "id": "2b0edb94045b1b70" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2e01ecd509708676", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/form-submit/{list_id}\")\n", "def options(list_id: str):\n", " headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': '*'}\n", " return Response(status_code=200, headers=headers)" - ], - "id": "e85e1be2cb22092c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "53e38d76286b1b72", + "metadata": {}, + "outputs": [], "source": [ "h = cli.options('/form-submit/2').headers\n", "test_eq(h['Access-Control-Allow-Methods'], 'POST')" - ], - "id": "6df13741986b614a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "from fasthtml.authmw import user_pwd_auth", - "id": "d44cca86642dbb96" + "id": "6dff912b083465bc", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.authmw import user_pwd_auth" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7548a47348034cec", + "metadata": {}, + "outputs": [], "source": [ "def _not_found(req, exc): return Div('nope')\n", "\n", @@ -3165,14 +3244,14 @@ "txt = cli.get('/').text\n", "assert '
nope
' in txt\n", "assert '' in txt" - ], - "id": "cb5ccd1b519ff618" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2668d26c9512ef1a", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3181,14 +3260,14 @@ "\n", "assert 'Hello Uma, age 5' in cli.get('/uma/5').text\n", "assert '404 Not Found' in cli.get('/uma/five').text" - ], - "id": "12a2f2754d170003" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "37d3e15bcaa8a6ab", + "metadata": {}, + "outputs": [], "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3198,14 +3277,14 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ], - "id": "551a5adf263fa813" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "693350f5bae20896", + "metadata": {}, + "outputs": [], "source": [ "auth = user_pwd_auth(testuser='spycraft')\n", "app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n", @@ -3215,28 +3294,32 @@ "\n", "test_eq(cli.get('/locked').text, 'not authenticated')\n", "test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')" - ], - "id": "ef8748272d98da81" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script.", - "id": "49d680d4c413cfcb" + "id": "7878e0f298745bb6", + "metadata": {}, + "source": [ + "HTMX4 fragment requests receive toast partials. Full pages render the toast container. Set `htmx=False, htmx4=True` when supplying your own HTMX4 script." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "from fasthtml.toaster import *", - "id": "df8ff4ec8f55fe2" + "id": "ea6ae9168eea4edb", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.toaster import *" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "43d640dd834f7754", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML(htmx=False, htmx4=True, hdrs=[htmx4src]))\n", "setup_toasts(app)\n", @@ -3258,28 +3341,32 @@ "legacy_rt('/')(index)\n", "r = legacy_cli.get('/', **hxhdr)\n", "assert tm in r.text and 'hx-swap-oob=' in r.text" - ], - "id": "cedbb3bedf22c504" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762).", - "id": "273c37c465affa62" + "id": "9c6af8473dc6a8b8", + "metadata": {}, + "source": [ + "Here's a quick sanity check to confirm that toasts are rendered in the toast container correctly. See [issue](https://github.com/AnswerDotAI/fasthtml/issues/762)." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "assert 'id=\"fh-toast-container\"' in r.text", - "id": "ea958d51eda2f8fc" + "id": "2989e8b0111f0290", + "metadata": {}, + "outputs": [], + "source": [ + "assert 'id=\"fh-toast-container\"' in r.text" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d8abe51f9a1245e8", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML(on_startup=[lambda: setattr(app, '_started', True)],\n", " on_shutdown=[lambda: setattr(app, '_stopped', True)]))\n", @@ -3289,14 +3376,14 @@ "\n", "with cli: test_eq(cli.get('/').text, 'True')\n", "test_eq(app._stopped, True)" - ], - "id": "1afbcb5da9793eb9" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c14bcde18b9cc46f", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3307,20 +3394,22 @@ "def index(): return app._evt\n", "\n", "with cli: test_eq(cli.get('/').text, 'fired')" - ], - "id": "88360e6d644ab9d3" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A route-level `before` can set session data, short-circuit with a response, or raise to block access:", - "id": "8900d323d981c71d" + "id": "421320e5cdf4c386", + "metadata": {}, + "source": [ + "A route-level `before` can set session data, short-circuit with a response, or raise to block access:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6cf4a83e49cb21c3", + "metadata": {}, + "outputs": [], "source": [ "def check_user(sess, user:str=''):\n", " if user == 'admin':\n", @@ -3335,20 +3424,22 @@ "test_eq(cli.get('/dashboard?user=admin').text, 'bar')\n", "test_eq(cli.get('/dashboard?user=guest').text, 'guest view')\n", "test_eq(cli.get('/dashboard?user=other').status_code, 403)" - ], - "id": "c9f08a6740f5ac09" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Multiple `before` functions run in order, each receiving the injected request params:", - "id": "88fbbfc231b8d60f" + "id": "f30c0190cbffc76a", + "metadata": {}, + "source": [ + "Multiple `before` functions run in order, each receiving the injected request params:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "cc638c1ceed7c22c", + "metadata": {}, + "outputs": [], "source": [ "def set_a(sess): sess['a'] = 1\n", "def set_b(sess): sess['b'] = 2\n", @@ -3357,20 +3448,22 @@ "def get(sess): return f\"{sess.get('a')},{sess.get('b')}\"\n", "\n", "test_eq(cli.get('/multi').text, '1,2')" - ], - "id": "413103e9e06d1f76" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):", - "id": "566d13c827e388d9" + "id": "dc63cc1b1dec83b4", + "metadata": {}, + "source": [ + "A truthy return from any `before` becomes the response; remaining `before`s and the handler are skipped (return `None` to continue):" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b50f946c90a44d89", + "metadata": {}, + "outputs": [], "source": [ "calls = []\n", "def first(sess): \n", @@ -3383,20 +3476,22 @@ "\n", "test_eq(cli.get('/chain').text, 'stop')\n", "test_eq(calls, ['first'])" - ], - "id": "978cac3d69ba52a5" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:", - "id": "269b3e53bde24b5" + "id": "886f133f3cba0558", + "metadata": {}, + "source": [ + "`cancel_on_disconnect` handlers keep their normal parameters; the handler is cancelled if the client disconnects, or after `timeout` seconds if given:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f93de0e0b133064e", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -3405,14 +3500,14 @@ "async def cq(q:str): return f'got {q}'\n", "\n", "test_eq(cli.get('/cq?q=x').text, 'got x')" - ], - "id": "67a91b66a7b62d01" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3222d6012fd42a71", + "metadata": {}, + "outputs": [], "source": [ "@rt('/sleepy')\n", "@cancel_on_disconnect(timeout=0.01)\n", @@ -3421,20 +3516,22 @@ " return 'nope'\n", "\n", "test_fail(lambda: cli.get('/sleepy'))" - ], - "id": "422123f1a058c2a7" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:", - "id": "e6252b6b2a99767e" + "id": "1b8b45a215beaa5c", + "metadata": {}, + "source": [ + "On disconnect the handler is cancelled and an empty response returned, which the server discards since the client is gone. Here we simulate the ASGI `http.disconnect` message directly:" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "21a57425bf9e4f06", + "metadata": {}, "outputs": [], - "execution_count": null, "source": [ "async def _disc(): return {'type': 'http.disconnect'}\n", "cancelled = []\n", @@ -3452,20 +3549,22 @@ "\n", "test_eq(run_sync(_t()).status_code, 204)\n", "test_eq(cancelled, [True])" - ], - "id": "56c1535ac76e35fe" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## APIRouter", - "id": "b4c13346baae5c6" + "id": "11c7c66cb5b6b423", + "metadata": {}, + "source": [ + "## APIRouter" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f1c11644b46e01d1", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class RouteFuncs:\n", @@ -3477,14 +3576,14 @@ " try: return self._funcs[name]\n", " except KeyError: raise AttributeError(f\"No route named {name} found in route functions\")\n", " def __dir__(self): return list(self._funcs.keys())" - ], - "id": "2fee904cd591a8c3" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "890c6b36806499f6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class APIRouter:\n", @@ -3525,22 +3624,24 @@ " \"Add a websocket route at `path`\"\n", " def f(func=noop): return self.wss.append((func, f\"{self.prefix}{path}\", conn, disconn, name, middleware))\n", " return f" - ], - "id": "303886be15d05943" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "ar = APIRouter()", - "id": "c1dcefcdcfffdc93" + "id": "95d50b50f5d9166f", + "metadata": {}, + "outputs": [], + "source": [ + "ar = APIRouter()" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bc3c688f39cdbfbe", + "metadata": {}, + "outputs": [], "source": [ "@ar(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -3563,25 +3664,25 @@ "\n", "@ar.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ], - "id": "fce8a8e52c493277" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "65dc2189b63305d0", + "metadata": {}, + "outputs": [], "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar.to_app(app)" - ], - "id": "9556fa722d0e5beb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4569eb2492d2b8f7", + "metadata": {}, + "outputs": [], "source": [ "assert str(yoyo) == '/yoyo'\n", "# ensure route functions are properly discoverable on `APIRouter` and `APIRouter.rt_funcs`\n", @@ -3592,14 +3693,14 @@ "with ExceptionExpected(): ar.rt_funcs.blah()\n", "# ensure any route functions named using an HTTPMethod are not discoverable via `rt_funcs`\n", "assert \"get\" not in ar.rt_funcs._funcs.keys()" - ], - "id": "d9389522e7a6bcfa" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "4691737b6959d800", + "metadata": {}, + "outputs": [], "source": [ "test_eq(cli.get('/hi').text, 'Hi there')\n", "test_eq(cli.post('/hi').text, 'Postal')\n", @@ -3613,35 +3714,37 @@ "test_r(cli, '/class-cards/abc', '{\"id\":\"abc\",\"title\":\"Foo\"}', 'patch', data={'title':'Foo'})\n", "test_eq(cli.delete('/class-cards/abc').text, 'delete abc')\n", "test_eq(CardMethods.to(id='xyz'), '/class-cards/xyz')" - ], - "id": "429c74a5b46e3521" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "91d34d8ce1941c02", + "metadata": {}, + "outputs": [], "source": [ "with cli.websocket_connect('/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ], - "id": "4e2dcfb51498c6f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "ar2 = APIRouter(\"/products\")", - "id": "a5c4c82857be6a37" + "id": "dad9a562054f33b7", + "metadata": {}, + "outputs": [], + "source": [ + "ar2 = APIRouter(\"/products\")" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "cbe4d060eb0c8360", + "metadata": {}, + "outputs": [], "source": [ "@ar2(\"/hi\")\n", "def get(): return 'Hi there'\n", @@ -3662,25 +3765,25 @@ "\n", "@ar2.ws(\"/ws\")\n", "def ws(self, msg:str): return f\"Message text was: {msg}\"" - ], - "id": "ed31b4ccef71816a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "9cee8fcc80b25168", + "metadata": {}, + "outputs": [], "source": [ "app,cli,_ = get_cli(FastHTML())\n", "ar2.to_app(app)" - ], - "id": "f6f034517d061c12" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "360f421b7fa3be15", + "metadata": {}, + "outputs": [], "source": [ "assert str(yoyo) == '/products/yoyo'\n", "assert ar2.prefix == '/products'\n", @@ -3690,14 +3793,14 @@ "with ExceptionExpected(): ar2.blah()\n", "with ExceptionExpected(): ar2.rt_funcs.blah()\n", "assert \"get\" not in ar2.rt_funcs._funcs.keys()" - ], - "id": "cbbcda8c607b8fd7" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "812c7fe6bd514944", + "metadata": {}, + "outputs": [], "source": [ "test_eq(cli.get('/products/hi').text, 'Hi there')\n", "test_eq(cli.post('/products/hi').text, 'Postal')\n", @@ -3709,38 +3812,38 @@ "\n", "test_eq(cli.get('/products/products?id=7').text, 'product 7')\n", "test_eq(Products.to(id=7), '/products/products?id=7')" - ], - "id": "f545be46e68f5ca6" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bab842b20bf23833", + "metadata": {}, + "outputs": [], "source": [ "with cli.websocket_connect('/products/ws') as ws:\n", " ws.send_text('{\"msg\":\"Hi!\"}')\n", " data = ws.receive_text()\n", " assert data == 'Message text was: Hi!'" - ], - "id": "4a15b327c1a8628b" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c01860fe7517722d", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o))" - ], - "id": "58bb7ea442d40849" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "5528056ce46c6a8a", + "metadata": {}, + "outputs": [], "source": [ "@ar.get\n", "def hi2(): return 'Hi there'\n", @@ -3755,28 +3858,32 @@ "def _(): return 'Hi there'\n", "@ar2.post(\"/post2\")\n", "def _(): return 'Postal'" - ], - "id": "716a5164e7e652cc" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Extras", - "id": "59b28a4c87902015" + "id": "e64ba1044537a083", + "metadata": {}, + "source": [ + "## Extras" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))", - "id": "548666a35366fbb9" + "id": "f33d93dc0f7479a9", + "metadata": {}, + "outputs": [], + "source": [ + "app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "6d41906f21e2e5bb", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def cookie(key: str, value=\"\", max_age=None, expires=None, path=\"/\", domain=None, secure=False, httponly=False, samesite=\"lax\",):\n", @@ -3794,14 +3901,14 @@ " cookie[key][\"samesite\"] = samesite\n", " cookie_val = cookie.output(header=\"\").strip()\n", " return HttpHeader(\"set-cookie\", cookie_val)" - ], - "id": "33d84011f283d35a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "783b658eb1e826d6", + "metadata": {}, + "outputs": [], "source": [ "@rt(\"/setcookie\")\n", "def get(req): return cookie('now', datetime.now())\n", @@ -3812,28 +3919,28 @@ "print(cli.get('/setcookie').text)\n", "time.sleep(0.01)\n", "cli.get('/getcookie').text" - ], - "id": "f3b4174a290eb26d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "acfb97d2b978356", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def reg_re_param(m, s):\n", " \"Register a Starlette URL convertor named `m` matching regex `s`\"\n", " cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)\n", " register_url_convertor(m, cls())" - ], - "id": "6922cff2253eefbf" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b5aea8aaa5b024d2", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "# Starlette doesn't have the '?', so it chomps the whole remaining URL\n", @@ -3846,14 +3953,14 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`\"\n", " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" - ], - "id": "6bcdf84ea9fade04" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "864d550f5ede18f4", + "metadata": {}, + "outputs": [], "source": [ "reg_re_param(\"imgext\", \"ico|gif|jpg|jpeg|webm|pdf\")\n", "\n", @@ -3861,25 +3968,25 @@ "def get(fn:str, path:str, ext:str): return f\"Getting {fn}.{ext} from /{path}\"\n", "\n", "test_r(cli, '/static/foo/jph.me.ico', 'Getting jph.me.ico from /foo/')" - ], - "id": "cd88cb3834fd88d1" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "579601c30f1d5d5f", + "metadata": {}, + "outputs": [], "source": [ "app.static_route_exts()\n", "assert 'These are the source notebooks for FastHTML' in cli.get('/README.txt').text" - ], - "id": "c73ffbfbf21a7c85" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "179c165cf2d70fa0", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -3887,25 +3994,25 @@ " \"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')\"\n", " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" - ], - "id": "50859aff05c795" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2683163724620ca6", + "metadata": {}, + "outputs": [], "source": [ "app.static_route('.md', static_path='../..')\n", "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" - ], - "id": "5a845e1896123499" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "df8649e9ff8a4862", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class StaticNoCache(StaticFiles):\n", @@ -3914,14 +4021,14 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n", " return resp" - ], - "id": "aa20e2aaf3c31651" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "b4b419bffadedf7c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class StaticImmutable(StaticFiles):\n", @@ -3930,20 +4037,22 @@ " resp = super().file_response(*args, **kwargs)\n", " resp.headers.setdefault(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n", " return resp" - ], - "id": "12b070d471b17752" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide.", - "id": "10111d01584e2fed" + "id": "d373c46bcab32b6e", + "metadata": {}, + "source": [ + "These two classes are the poles of static-file caching. `StaticNoCache` makes the browser revalidate on every use: always fresh at the cost of a conditional request each time, right for files that change under a stable URL (and for dev, where you want edits visible on reload). `StaticImmutable` promises the URL's content will never change, so the browser may cache it forever with no requests at all: only safe when a changed file gets a changed URL, which is what `vurl` or a bundler's content-hashed filenames provide." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "d3da7872f46f03e7", + "metadata": {}, + "outputs": [], "source": [ "statd = Path(tempfile.mkdtemp())\n", "(statd/'app.css').write_text('body{}')\n", @@ -3951,14 +4060,14 @@ "stat_cli = TestClient(stat_app)\n", "test_eq(stat_cli.get('/nc/app.css').headers['cache-control'], 'no-cache')\n", "test_eq(stat_cli.get('/imm/app.css').headers['cache-control'], 'public, max-age=31536000, immutable')" - ], - "id": "5e643155b44b7f53" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "f650d7d18b386925", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@flexicache(mtime_policy(arg=0))\n", @@ -3970,20 +4079,22 @@ " p = Path(root)/u.path.lstrip('/')\n", " q = f'{u.query}&' if u.query else ''\n", " return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}'))\n" - ], - "id": "8456c4ce3b137c6" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload.", - "id": "e92816df7c68ad9e" + "id": "149b12cab8090a07", + "metadata": {}, + "source": [ + "Browsers use the URL as the cache key, so a URL that changes whenever the file's contents change can be cached forever: serve the mount with `StaticImmutable` and the asset costs no requests at all until it is next edited. This is the query-param version of the content-hashed filenames that asset pipelines and bundlers produce, without the build step. The hash is cached via `flexicache` with `mtime_policy`, so a call costs one `stat` and the file is only re-read after it changes. The param is inert under `no-cache` serving, so the same links work in dev with `StaticNoCache`, where the file is rechecked on each reload." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "10948c5179e59f52", + "metadata": {}, + "outputs": [], "source": [ "tmp = Path(tempfile.mkdtemp())\n", "(tmp/'a.css').write_text('body{}')\n", @@ -3996,26 +4107,26 @@ "(tmp/'a.css').write_text('body{color:red}')\n", "os.utime(tmp/'a.css', (time.time()+1,)*2)\n", "assert vurl('a.css', tmp) != u" - ], - "id": "d93300e58bf96cf2" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "e336fb23b6a4348", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "from functools import wraps\n", "from inspect import signature, isawaitable" - ], - "id": "532bd72387550a77" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "fffed22d52dd5db8", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty):\n", @@ -4027,14 +4138,14 @@ " f.__signature__ = sig.replace(parameters=new_params)\n", " f.__annotations__[name] = typ\n", " return f" - ], - "id": "93f8c1ac3ae4612" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2908db7385793cc5", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class into:\n", @@ -4057,26 +4168,30 @@ " anno = p.annotation if p.annotation is not Parameter.empty else NoneType\n", " add_sig_param(_inner, k, anno, default=p.default)\n", " return _inner" - ], - "id": "6672281bf9d8b0c6" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically.", - "id": "805c0d5c6e5300f9" + "id": "4f02960dbf53baf3", + "metadata": {}, + "source": [ + "`into` is a decorator that composes two functions: the decorated route function and an outer \"wrapper\" function (like a layout). The route's return value is passed as positional args to the wrapper. Any keyword parameters listed in `params` are added to the route's signature, so they become available as query/form parameters. This lets you write simple routes that return just their content, while the layout and its parameters are handled automatically." + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params.", - "id": "67d2afef245c1277" + "id": "6b5ba96c6d368a14", + "metadata": {}, + "source": [ + "Here `hello` returns a single `P` tag, but `into(my_layout)` passes it into `my_layout`, which wraps it in a `Titled`. The `title` parameter is injected into `hello`'s signature so it can be set via query params." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "236e1df02b7ecc4d", + "metadata": {}, + "outputs": [], "source": [ "app,cli,rt = get_cli(FastHTML())\n", "\n", @@ -4085,50 +4200,54 @@ "@rt\n", "@into(my_layout)\n", "def hello(name:str='World'): return P(f'Hello, {name}!')" - ], - "id": "b149ac59a7a9b0e0" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\".", - "id": "fc41c37ab4b42c3" + "id": "bfd647315eff2dcc", + "metadata": {}, + "source": [ + "With no query params, `name` defaults to `'World'` and `title` defaults to `'Default'` (from `my_layout`'s own default), giving us a page titled \"Default\" containing \"Hello, World!\"." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "63f14214f7566860", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/hello')\n", "assert 'Default' in r.text\n", "assert '

Hello, World!

' in r.text" - ], - "id": "9ed0441e7450245e" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`.", - "id": "d84f8ac0b32564eb" + "id": "176b7099bc8abccf", + "metadata": {}, + "source": [ + "Here, both `name` and `title` are provided as query params. `name='Jeremy'` is handled by `hello` directly, while `title='Greetings'` is the injected param that gets forwarded to `my_layout`." + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c12782a920534773", + "metadata": {}, + "outputs": [], "source": [ "r = cli.get('/hello?name=Jeremy&title=Greetings')\n", "assert 'Greetings' in r.text\n", "assert '

Hello, Jeremy!

' in r.text" - ], - "id": "9c7aca3c694ca5be" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7f46d6e01ce2773c", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class MiddlewareBase:\n", @@ -4137,14 +4256,14 @@ " if scope[\"type\"] in (\"http\", \"websocket\"): return HTTPConnection(scope)\n", " await self._app(scope, receive, send)\n", " " - ], - "id": "31f7ab28650f32bc" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bd853c5a477ed976", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "class FtResponse:\n", @@ -4160,14 +4279,14 @@ " if not tasks: tasks = self.background\n", " headers = {**(self.headers or {}), **httphdrs}\n", " return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks)" - ], - "id": "8a73b905220e2a4e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ddc9f8c2a6ff15e", + "metadata": {}, + "outputs": [], "source": [ "@rt('/ftr')\n", "def get():\n", @@ -4180,20 +4299,22 @@ "test_eq(r.headers['location'], '/foo/1')\n", "txt = r.text\n", "assert 'Foo' in txt and '

bar

' in txt and '' in txt" - ], - "id": "98bdffbc0fdf2242" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Test on a single background task:", - "id": "cf7364932d6c113b" + "id": "570cebd3f8d38359", + "metadata": {}, + "source": [ + "Test on a single background task:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "9452506485f1ae1", + "metadata": {}, + "outputs": [], "source": [ "def my_slow_task():\n", " print('Starting slow task') \n", @@ -4207,34 +4328,36 @@ "\n", "test_eq(r.status_code, 200)\n", "\n" - ], - "id": "9bedecb86cf6ade6" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Test multiple background tasks:", - "id": "b2d690b86e2095fe" + "id": "18cebd579c203d68", + "metadata": {}, + "source": [ + "Test multiple background tasks:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3533bbb53fd781f2", + "metadata": {}, + "outputs": [], "source": [ "def increment(amount):\n", " amount = amount/1000\n", " print(f'Sleeping for {amount}s') \n", " time.sleep(amount)\n", " print(f'Slept for {amount}s') " - ], - "id": "3e8dde53f0ffe20d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "642d1d90595ca371", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def backgrounds():\n", @@ -4244,14 +4367,14 @@ "\n", "r = cli.get('/backgrounds')\n", "test_eq(r.status_code, 200)" - ], - "id": "f0b4f8de33eb6a7c" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "c185af1365da4fcc", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def backgrounds2():\n", @@ -4260,14 +4383,14 @@ "\n", "r = cli.get('/backgrounds2')\n", "test_eq(r.status_code, 200)" - ], - "id": "e16949201c9fd37d" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "2e211cf2afdf45fa", + "metadata": {}, + "outputs": [], "source": [ "@rt\n", "def backgrounds3():\n", @@ -4277,14 +4400,14 @@ "r = cli.get('/backgrounds3')\n", "test_eq(r.status_code, 200)\n", "r.json()" - ], - "id": "28bf53e773c2508a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "78bc2834a3ea6cda", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def unqid(seeded=False):\n", @@ -4292,28 +4415,28 @@ " id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4()\n", " res = b64encode(id4.bytes)\n", " return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))" - ], - "id": "ffe9ca3ec6694f2e" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7c977335a4ba289b", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "def _add_ids(s):\n", " if not isinstance(s, FT): return\n", " if not getattr(s, 'id', None): s.id = unqid()\n", " for c in s.children: _add_ids(c)" - ], - "id": "d26ac72480caf92f" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "1a6b71aa1ba2e789", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4326,20 +4449,22 @@ " for o in conns.values(): await o(s)\n", " app._send = send\n", " return send" - ], - "id": "ce9ce4311e72df65" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:", - "id": "442ea36df3e1417f" + "id": "ac318c208c76d21a", + "metadata": {}, + "source": [ + "`setup_ws` returns an async function that broadcasts FT elements to connected clients. Here a message handler broadcasts each incoming message. Clients can disconnect and reconnect:" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "3ac0ed38bb773298", + "metadata": {}, + "outputs": [], "source": [ "push_app = FastHTML()\n", "async def relay(msg:str): await push(Div(msg, id='status'))\n", @@ -4349,14 +4474,14 @@ " with client.websocket_connect('/ws') as ws:\n", " ws.send_json(dict(msg=msg))\n", " assert msg in ws.receive_text()" - ], - "id": "1b0edc880dc5c73a" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "699ee6943e1c8ac6", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "devtools_loc = \"/.well-known/appspecific/com.chrome.devtools.json\"\n", @@ -4367,14 +4492,14 @@ " if not uuid: uuid = str(uuid5(NAMESPACE_URL, str(path)))\n", " @self.route(devtools_loc)\n", " def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))" - ], - "id": "2f4989eb010bc1bb" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "75158856bbe1f5bf", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4387,14 +4512,14 @@ " client = httpx2.AsyncClient() if asink else httpx2.Client()\n", " client.cookies.update({self.session_cookie: data.decode()})\n", " return client" - ], - "id": "a93ba5a91685e0b2" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "bb041161d82c7ec8", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4403,14 +4528,14 @@ " if not cookie or cookie == 'null': return {}\n", " unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None)\n", " return loads(b64decode(unsigned).decode())" - ], - "id": "5f14a24a1fc21cda" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "8e3efceea8c5b4e9", + "metadata": {}, + "outputs": [], "source": [ "#| export\n", "@patch\n", @@ -4426,37 +4551,38 @@ " return self.app.decode_session(cookie)\n", "\n", " return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)" - ], - "id": "ba3d97d61961e0f4" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "74649fa5b13819c0", + "metadata": {}, + "outputs": [], "source": [ "cli = app.get_testclient(auth='user1')\n", "test_eq(cli.session['auth'], 'user1')" - ], - "id": "a7f08c6be1b8889d" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "## Export -", - "id": "48e4bdd27c31e33a" + "id": "be77893080870385", + "metadata": {}, + "source": [ + "## Export -" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "da45ad161ba35516", + "metadata": {}, + "outputs": [], "source": [ "#| hide\n", "import nbdev\n", "nbdev.nbdev_export()" - ], - "id": "925c4a8102e23e54" + ] } ], "metadata": { From 9fe5b41a4a396dac5e10df30fb4666a7bbae5bb5 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Wed, 21 Oct 2026 12:43:59 -0300 Subject: [PATCH 19/22] refactor: update request parameter aliases to camelCase and arbitrary. --- tests/test_alias.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_alias.py b/tests/test_alias.py index 059fe82b..8fd4138b 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -6,37 +6,37 @@ cli = TestClient(app) @rt("/query") -def get(user_id: Annotated[str, Alias("user-id")]): return f"q:{user_id}" +def get(user_id: Annotated[str, Alias("userId")]): return f"q:{user_id}" @rt("/form", methods=["post"]) -def post(user_id: Annotated[str, Alias("user-id")]): return f"f:{user_id}" +def post(user_id: Annotated[str, Alias("userId")]): return f"f:{user_id}" -@rt("/path/{item_id}") -def get(item_id: Annotated[int, Alias("item_id")]): return f"p:{item_id}" +@rt("/path/{itemId}") +def get(item_id: Annotated[int, Alias("itemId")]): return f"p:{item_id}" @rt("/header") -def get(x_api_key: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key}" +def get(x_api_key_hdr: Annotated[str, Alias("X-Api-Key")]): return f"h:{x_api_key_hdr}" @rt("/cookie") -def get(req, session_id: Annotated[str, Alias("session-id")]): return f"c:{session_id}" +def get(req, sid_cookie: Annotated[str, Alias("session-id")]): return f"c:{sid_cookie}" @rt("/int") -def get(count: Annotated[int, Alias("the-count")]): return f"i:{count + 1}" +def get(count: Annotated[int, Alias("theCount")]): return f"i:{count + 1}" @rt("/plain") def get(user_id: str): return f"plain:{user_id}" @rt("/missing") -def get(user_id: Annotated[str, Alias("user-id")]): return user_id +def get(user_id: Annotated[str, Alias("userId")]): return user_id -def test_query_alias(): assert cli.get('/query?user-id=abc').text == 'q:abc' -def test_form_alias(): assert cli.post('/form', data={'user-id': 'abc'}).text == 'f:abc' +def test_query_alias(): assert cli.get('/query?userId=abc').text == 'q:abc' +def test_form_alias(): assert cli.post('/form', data={'userId': 'abc'}).text == 'f:abc' def test_path_alias(): assert cli.get('/path/7').text == 'p:7' def test_header_alias(): assert cli.get('/header', headers={'X-Api-Key': 'k1'}).text == 'h:k1' def test_cookie_alias(): assert cli.get('/cookie', cookies={'session-id': 's1'}).text == 'c:s1' -def test_int_alias(): assert cli.get('/int?the-count=4').text == 'i:5' -def test_plain_unchanged(): assert cli.get('/plain?user_id=z').text == 'plain:z' -def test_snake_name_not_used_for_alias(): assert cli.get('/query?user_id=abc').status_code == 400 +def test_int_alias(): assert cli.get('/int?theCount=4').text == 'i:5' +def test_plain_unchanged(): assert cli.get('/plain?user-id=z').text == 'plain:z' +def test_alias_is_case_sensitive(): assert cli.get('/query?userID=abc').status_code == 400 def test_missing_message_uses_alias(): r = cli.get('/missing') - assert r.status_code == 400 and 'Missing required field: user-id' in r.text + assert r.status_code == 400 and 'Missing required field: userId' in r.text From eca961fba4bd9ab60bdd3ef651d1c1d8d17c6d78 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 3 Nov 2026 23:10:51 -0300 Subject: [PATCH 20/22] fix: use request alias for header lookup in _find_p --- fasthtml/core.py | 2 +- nbs/api/00_core.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 3a462819..e4c9bb45 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -310,7 +310,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): # Not a special name or a special annotation res = conn.path_params.get(key, None) if res in (empty,None): res = conn.cookies.get(key, None) - if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None) + if res in (empty,None): res = hdrs.get(snake2hyphens(key), None) if res in (empty,None): res = _formitem(conn.query_params, key) if res in (empty,None): res = _formitem(data, key) if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 93af28cd..a8d9e8fa 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -841,7 +841,7 @@ " # Not a special name or a special annotation\n", " res = conn.path_params.get(key, None)\n", " if res in (empty,None): res = conn.cookies.get(key, None)\n", - " if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None)\n", + " if res in (empty,None): res = hdrs.get(snake2hyphens(key), None)\n", " if res in (empty,None): res = _formitem(conn.query_params, key)\n", " if res in (empty,None): res = _formitem(data, key)\n", " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values`\n", From 9d6ff61ad2a941d1fb9e4746412b8768843c9435 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 3 Nov 2026 23:19:30 -0300 Subject: [PATCH 21/22] fix: decode header params via _get_header and fix notebook cell order Move the _from_body test cell after _find_ps is defined, and route header lookups in _find_p through _get_header so HX-Prompt is URI-decoded. --- fasthtml/core.py | 2 +- nbs/api/00_core.ipynb | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index e4c9bb45..557816be 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -310,7 +310,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): # Not a special name or a special annotation res = conn.path_params.get(key, None) if res in (empty,None): res = conn.cookies.get(key, None) - if res in (empty,None): res = hdrs.get(snake2hyphens(key), None) + if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key)) if res in (empty,None): res = _formitem(conn.query_params, key) if res in (empty,None): res = _formitem(data, key) if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index a8d9e8fa..102d7246 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -841,7 +841,7 @@ " # Not a special name or a special annotation\n", " res = conn.path_params.get(key, None)\n", " if res in (empty,None): res = conn.cookies.get(key, None)\n", - " if res in (empty,None): res = hdrs.get(snake2hyphens(key), None)\n", + " if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key))\n", " if res in (empty,None): res = _formitem(conn.query_params, key)\n", " if res in (empty,None): res = _formitem(data, key)\n", " if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values`\n", @@ -859,25 +859,25 @@ { "cell_type": "code", "execution_count": null, - "id": "333f3e9cee44b116", + "id": "40f862f356ae9ebb", "metadata": {}, "outputs": [], "source": [ - "data = form2dict(await parse_form(req))\n", - "await _from_body(req, p, data)" + "#| export\n", + "async def _find_ps(conn, data, hdrs, params):\n", + " if conn.query_params: data |= dict(conn.query_params)\n", + " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" ] }, { "cell_type": "code", "execution_count": null, - "id": "40f862f356ae9ebb", + "id": "333f3e9cee44b116", "metadata": {}, "outputs": [], "source": [ - "#| export\n", - "async def _find_ps(conn, data, hdrs, params):\n", - " if conn.query_params: data |= dict(conn.query_params)\n", - " return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()}" + "data = form2dict(await parse_form(req))\n", + "await _from_body(req, p, data)" ] }, { From 968d94ed2fabde67e7f5ae20f6402e3f7d853280 Mon Sep 17 00:00:00 2001 From: Renato Gomes Damas Date: Tue, 10 Nov 2026 16:11:34 -0300 Subject: [PATCH 22/22] feat: add Alias class for handler parameter aliasing in requests --- fasthtml/core.py | 263 ++++++++++++++++++++++++----------------------- 1 file changed, 137 insertions(+), 126 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 65a1ac11..609b8068 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -18,7 +18,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. -Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: +Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. To read a different request name, use `Annotated[T, Alias('name')]`. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: req, ws the Request / WebSocket connection sess session dict @@ -48,13 +48,13 @@ class Cards: ... # class route group: each verb-named method (get, patch, . __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', - 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', - 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', + 'JSONResponse', 'Alias', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', + 'flat_tuple', 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'BearerSessionMiddleware', 'FastHTML', 'HostRoute', 'nested_name', 'serve', 'until_disconnect', 'cancel_on_disconnect', 'Client', 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', 'StaticImmutable', 'vurl', 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid'] -# %% ../nbs/api/00_core.ipynb #23503b9e +# %% ../nbs/api/00_core.ipynb #c9ebcfc30a1f800 import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess from uuid import uuid5, NAMESPACE_URL @@ -64,7 +64,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from fastcore.style import S from types import UnionType, SimpleNamespace as ns, GenericAlias -from typing import get_args, get_origin, Union, Mapping, List, Any, Callable +from typing import get_args, get_origin, Union, Mapping, List, Any, Callable, Annotated from datetime import datetime,date from dataclasses import dataclass from inspect import Parameter,get_annotations @@ -82,23 +82,23 @@ class Cards: ... # class route group: each verb-named method (get, patch, . from .starlette import * from python_multipart.exceptions import MultipartParseError -# %% ../nbs/api/00_core.ipynb #19d3f2a7 +# %% ../nbs/api/00_core.ipynb #812ef9183ad0b657 def _params(f): return signature_ex(f, True).parameters empty = Parameter.empty -# %% ../nbs/api/00_core.ipynb #e68a76c9 +# %% ../nbs/api/00_core.ipynb #c5028c09a628533 def parsed_date(s:str): "Convert `s` to a datetime" return dtparse.parse(s) -# %% ../nbs/api/00_core.ipynb #7c820373 +# %% ../nbs/api/00_core.ipynb #160b6878e8a6a74d def snake2hyphens(s:str): "Convert `s` from snake case to hyphenated and capitalised" s = snake2camel(s) return camel2words(s, '-') -# %% ../nbs/api/00_core.ipynb #25f3b8f8 +# %% ../nbs/api/00_core.ipynb #31fb5e7526f55d26 htmx_hdrs = dict( boosted="HX-Boosted", current_url="HX-Current-URL", @@ -129,13 +129,13 @@ def _get_htmx(h): res = {k:_get_header(h, v) for k,v in htmx_hdrs.items()} return HtmxHeaders(**res) -# %% ../nbs/api/00_core.ipynb #1d53e8e7 +# %% ../nbs/api/00_core.ipynb #6a06097681bcd6a6 def _mk_list(t, v): return [t(o) for o in listify(v)] -# %% ../nbs/api/00_core.ipynb #5ab74473 +# %% ../nbs/api/00_core.ipynb #12f93b7aa5424194 fh_cfg = AttrDict(indent=True) -# %% ../nbs/api/00_core.ipynb #88d12e68 +# %% ../nbs/api/00_core.ipynb #58660171446e4348 _special_names = {'ws','request','session','scope','data','htmx','app','state','auth','send','api','body','hdrs','ftrs','bodykw','htmlkw','resp','self'} def _check_anno(arg, anno): @@ -143,7 +143,7 @@ def _check_anno(arg, anno): if anno is empty and arg.lower() not in _special_names and not any(s.startswith(arg.lower()) for s in ('request','session')): return f"`{arg}` has no type annotation and is not a recognised special name, so is ignored." if isinstance(anno, type) and not get_origin(anno) and issubclass(anno, (list, tuple)) and not _is_body(anno): return f"`{arg}` uses bare `{anno.__name__}` annotation, so is ignored (use e.g. `{anno.__name__}[str]` instead)." -# %% ../nbs/api/00_core.ipynb #0afb520c +# %% ../nbs/api/00_core.ipynb #a6c4d5dec9a11382 def _fix_anno(t, o): "Create appropriate callable type for casting a `str` to type `t` (or first type in `t` if union)" origin = get_origin(t) @@ -156,7 +156,7 @@ def _fix_anno(t, o): if not isinstance(o, (str,list,tuple)): return o return res(o[-1]) if isinstance(o,(list,tuple)) else res(o) -# %% ../nbs/api/00_core.ipynb #c58ccadb +# %% ../nbs/api/00_core.ipynb #10852e6f03bb7231 def _form_arg(k, v, d): "Get type by accessing key `k` from `d`, and use to cast `v`" if v is None: return @@ -166,35 +166,35 @@ def _form_arg(k, v, d): if not anno: return v return _fix_anno(anno, v) -# %% ../nbs/api/00_core.ipynb #5fc04751 +# %% ../nbs/api/00_core.ipynb #b40f180dae734a89 @dataclass class HttpHeader: "A response header as a k,v pair" k:str;v:str -# %% ../nbs/api/00_core.ipynb #94e18161 +# %% ../nbs/api/00_core.ipynb #3cf407bbe6d72fce def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title() htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None, reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None) -# %% ../nbs/api/00_core.ipynb #f6a2e62e +# %% ../nbs/api/00_core.ipynb #2cf50288f5a348f @use_kwargs_dict(**htmx_resps) def HtmxResponseHeaders(**kwargs): "HTMX response headers" res = tuple(HttpHeader(_to_htmx_header(k), v) for k,v in kwargs.items()) return res[0] if len(res)==1 else res -# %% ../nbs/api/00_core.ipynb #56cc589f +# %% ../nbs/api/00_core.ipynb #922afe94af9a5100 def _annotations(anno): "Same as `get_annotations`, but also works on namedtuples" if is_namedtuple(anno): return {o:str for o in anno._fields} return get_annotations(anno) -# %% ../nbs/api/00_core.ipynb #cb4ed4aa +# %% ../nbs/api/00_core.ipynb #5a1fa453f1caafa7 def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_request__') or _annotations(anno) -# %% ../nbs/api/00_core.ipynb #ffdde66f +# %% ../nbs/api/00_core.ipynb #84fe8f035ba3132d def _formitem(form, k): "Return item `k`, accepting hyphens for underscores and unwrapping single-item form lists" if k not in form: k = {o.replace('-', '_'):o for o in form}.get(k, k) @@ -202,16 +202,16 @@ def _formitem(form, k): o = form.getlist(k) return o[0] if len(o) == 1 else o if o else None -# %% ../nbs/api/00_core.ipynb #5fe74444 +# %% ../nbs/api/00_core.ipynb #ba55a6ab2bf79e81 def form2dict(form: FormData) -> dict: "Convert starlette form data to a dict" if isinstance(form, dict): return form return {k: _formitem(form, k) for k in form} -# %% ../nbs/api/00_core.ipynb #8899bc0a +# %% ../nbs/api/00_core.ipynb #7154b82a09979e0a DEF_MAXPART = 100*1024*1024 -# %% ../nbs/api/00_core.ipynb #42c9cea0 +# %% ../nbs/api/00_core.ipynb #1f95924092f9c33d async def parse_form(req: Request) -> FormData: "Starlette errors on empty multipart/json forms and 500s on malformed multipart, so this handles those situations" ctype = req.headers.get("Content-Type", "") @@ -230,7 +230,7 @@ async def parse_form(req: Request) -> FormData: return res return await req.form(max_part_size=maxpart) -# %% ../nbs/api/00_core.ipynb #0caedd04 +# %% ../nbs/api/00_core.ipynb #8e8b9056b2f3a099 async def _from_body(conn, p, data): "Create an instance of the annotated type from pre-parsed `data`" anno = p.annotation @@ -246,7 +246,7 @@ async def _from_body(conn, p, data): cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d} return anno(**cargs) -# %% ../nbs/api/00_core.ipynb #88b6da3f +# %% ../nbs/api/00_core.ipynb #a73dc9076461a6a9 class ApiReturn: "Request param: call with browser and API responses; returns kwargs as JSON if the client accepts `application/json`, else `norm`" @classmethod @@ -255,7 +255,7 @@ def __init__(self, isapi=False): self.isapi = isapi def __call__(self, norm=None, **kw): return kw if self.isapi else norm def __bool__(self): return bool(self.isapi) -# %% ../nbs/api/00_core.ipynb #7cc39ba9 +# %% ../nbs/api/00_core.ipynb #c835e81d6e4f5efd class JSONResponse(JSONResponseOrig): "Same as starlette's version, but auto-stringifies non serializable types" def render(self, content:Any)->bytes: @@ -264,9 +264,20 @@ def _default(o): return list(o) if is_listy(o) else str(o) return res.encode("utf-8") -# %% ../nbs/api/00_core.ipynb #5fa96e3a +# %% ../nbs/api/00_core.ipynb #4e1d265576d2d438 +@dataclass(frozen=True) +class Alias: + "Read a handler param from request data under `name` instead of its Python name, e.g. `Annotated[str, Alias('user-id')]`" + name: str + async def _find_p(conn, data, hdrs, arg:str, p:Parameter): "In `data` find param named `arg` of type in `p` (`arg` is ignored for body types)" + alias = None + if get_origin(p.annotation) is Annotated: + anno, *meta = get_args(p.annotation) + p = p.replace(annotation=anno) + alias = next((m.name for m in meta if isinstance(m, Alias)), None) + key = alias or arg anno = p.annotation # Special annotation types if isinstance(anno, type) and not isinstance(anno, GenericAlias): @@ -276,54 +287,54 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if issubclass(anno, State): return conn.scope['app'].state if anno is dict: return data if _is_body(anno): - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) return await _from_body(conn, p, data) - if (msg := _check_anno(arg, anno)): return warn(msg) + if (msg := _check_anno(key, anno)): return warn(msg) # Special param names with no annotations if anno is empty: - if arg.lower()=='ws' or 'request'.startswith(arg.lower()): return conn - if 'session'.startswith(arg.lower()): return conn.scope.get('session', {}) - if arg.lower()=='scope': return conn.scope - if arg.lower()=='data': return data - if arg.lower()=='htmx': return _get_htmx(hdrs) - if arg.lower()=='app': return conn.scope['app'] - if arg.lower()=='state': return conn.scope['app'].state - if arg.lower()=='auth': return conn.scope.get('auth', None) - if arg.lower()=='send': + if key.lower()=='ws' or 'request'.startswith(key.lower()): return conn + if 'session'.startswith(key.lower()): return conn.scope.get('session', {}) + if key.lower()=='scope': return conn.scope + if key.lower()=='data': return data + if key.lower()=='htmx': return _get_htmx(hdrs) + if key.lower()=='app': return conn.scope['app'] + if key.lower()=='state': return conn.scope['app'].state + if key.lower()=='auth': return conn.scope.get('auth', None) + if key.lower()=='send': assert not isinstance(conn, Request), "`send` requires a websocket, not a `Request`" return partial(_send_ws, conn) - if arg.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json') - if arg.lower()=='body': return (await conn.body()).decode() - if arg.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, arg.lower()) + if key.lower()=='api': return ApiReturn(hdrs.get('accept')=='application/json') + if key.lower()=='body': return (await conn.body()).decode() + if key.lower() in ('hdrs','ftrs','bodykw','htmlkw'): return getattr(conn, key.lower()) return None # Not a special name or a special annotation - res = conn.path_params.get(arg, None) - if res in (empty,None): res = conn.cookies.get(arg, None) - if res in (empty,None): res = _get_header(hdrs, snake2hyphens(arg)) - if res in (empty,None): res = _formitem(conn.query_params, arg) - if res in (empty,None): res = _formitem(data, arg) - if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, arg) # htmx v4 nests form data under `values` + res = conn.path_params.get(key, None) + if res in (empty,None): res = conn.cookies.get(key, None) + if res in (empty,None): res = _get_header(hdrs, snake2hyphens(key)) + if res in (empty,None): res = _formitem(conn.query_params, key) + if res in (empty,None): res = _formitem(data, key) + if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, key) # htmx v4 nests form data under `values` if res in (empty,None): if p.default is empty: - if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") - raise ValueError(f"Missing required field: {arg}") + if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {key}") + raise ValueError(f"Missing required field: {key}") res = p.default try: return _fix_anno(anno, res) except ValueError as e: if isinstance(conn, Request): raise HTTPException(404, f"{conn.url.path}: {e}") from None raise -# %% ../nbs/api/00_core.ipynb #bf42edad +# %% ../nbs/api/00_core.ipynb #40f862f356ae9ebb async def _find_ps(conn, data, hdrs, params): if conn.query_params: data |= dict(conn.query_params) return {arg:await _find_p(conn, data, hdrs, arg, p) for arg,p in params.items()} -# %% ../nbs/api/00_core.ipynb #090f1f0f +# %% ../nbs/api/00_core.ipynb #f88072f39efb683e async def _wrap_req(req, params): data = form2dict(await parse_form(req)) return await _find_ps(req, data, req.headers, params) -# %% ../nbs/api/00_core.ipynb #7a661bfa +# %% ../nbs/api/00_core.ipynb #2891fffa291ffaa9 def flat_xt(lst): "Flatten lists" result = [] @@ -333,23 +344,23 @@ def flat_xt(lst): else: result.append(item) return tuple(result) -# %% ../nbs/api/00_core.ipynb #aacff5ac +# %% ../nbs/api/00_core.ipynb #7f97f41c7180179a class Beforeware: "Wrap a `before` function with `skip` path patterns that bypass it" def __init__(self, f, skip=None): self.f,self.skip = f,skip or [] def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})' -# %% ../nbs/api/00_core.ipynb #78c3c357 +# %% ../nbs/api/00_core.ipynb #24171b42d8ed41ee async def _handle(f, /, *args, **kwargs): return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs) -# %% ../nbs/api/00_core.ipynb #ad0f0e87 +# %% ../nbs/api/00_core.ipynb #dc78b1c31eb67d83 async def _wrap_ws(ws, data, params): raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers` hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None}) return await _find_ps(ws, data, hdrs, params) -# %% ../nbs/api/00_core.ipynb #dcc15129 +# %% ../nbs/api/00_core.ipynb #29df49dfbdbeab89 async def _send_ws(ws, resp): if not resp: return res = to_xml(resp, indent=fh_cfg.indent) @@ -377,12 +388,12 @@ async def _recv(self, ws, data): await _generic_handler(recv, ws, data) cls.on_receive = _recv return cls -# %% ../nbs/api/00_core.ipynb #5b0e7677 +# %% ../nbs/api/00_core.ipynb #83fa302372e0e042 def EventStream(s): "Create a text/event-stream response from `s`" return StreamingResponse(s, media_type="text/event-stream") -# %% ../nbs/api/00_core.ipynb #0dd0a414 +# %% ../nbs/api/00_core.ipynb #f588429f00c732e7 def signal_shutdown(): "Return an `asyncio.Event` set when uvicorn receives a shutdown signal" from uvicorn.main import Server @@ -394,21 +405,21 @@ def handle_exit(self:Server, *args, **kwargs): self._orig_handle_exit(*args, **kwargs) return event -# %% ../nbs/api/00_core.ipynb #03f4c639 +# %% ../nbs/api/00_core.ipynb #1537735647b1227f def uri(_arg, **kwargs): "Create a URI by URL-encoding `_arg` and appending query parameters from `kwargs`" return f"{quote(_arg)}/{urlencode(kwargs, doseq=True)}" -# %% ../nbs/api/00_core.ipynb #bdb2ac14 +# %% ../nbs/api/00_core.ipynb #3004b7d11797405c def decode_uri(s): "Decode a URI created by `uri()` back into argument and keyword dict" arg,_,kw = s.partition('/') return unquote(arg), {k:v[0] for k,v in parse_qs(kw).items()} -# %% ../nbs/api/00_core.ipynb #b80ce139 +# %% ../nbs/api/00_core.ipynb #e9de9e161764947b from starlette.convertors import StringConvertor -# %% ../nbs/api/00_core.ipynb #a27adc1e +# %% ../nbs/api/00_core.ipynb #b9bb3ffe99344740 StringConvertor.regex = "[^/]*" # `+` replaced with `*` @patch @@ -418,13 +429,13 @@ def to_string(self:StringConvertor, value: str) -> str: # assert value, "Must not be empty" # line removed due to errors return value -# %% ../nbs/api/00_core.ipynb #46614165 +# %% ../nbs/api/00_core.ipynb #cccf24a4868dd227 @patch def url_path_for(self:HTTPConnection, name: str, **path_params): lp = self.scope['app'].url_path_for(name, **path_params) return URLPath(f"{self.scope['root_path']}{lp}", lp.protocol, lp.host) -# %% ../nbs/api/00_core.ipynb #c1707d59 +# %% ../nbs/api/00_core.ipynb #535fa019103e6f9d _verbs = dict(get='hx-get', post='hx-post', put='hx-put', delete='hx-delete', patch='hx-patch', link='href') def _url_for(req, t): @@ -450,10 +461,10 @@ def _to_xml(req, resp, indent): _find_targets(req, resp) return to_xml(resp, indent=indent) -# %% ../nbs/api/00_core.ipynb #f1e3ed2d +# %% ../nbs/api/00_core.ipynb #69d2044af9aa17d4 _iter_typs = (tuple,list,map,filter,range,types.GeneratorType) -# %% ../nbs/api/00_core.ipynb #a407dc0e +# %% ../nbs/api/00_core.ipynb #fecf15a81dec5af7 def flat_tuple(o): "Flatten nested iterables into a single tuple" result = [] @@ -464,12 +475,12 @@ def flat_tuple(o): else: result.append(item) return tuple(result) -# %% ../nbs/api/00_core.ipynb #d36402e6 +# %% ../nbs/api/00_core.ipynb #f50bb04220c638a9 def noop_body(c, req): "Default Body wrap function which just returns the content" return c -# %% ../nbs/api/00_core.ipynb #7f49728d +# %% ../nbs/api/00_core.ipynb #d7ae82dfa40eb3f2 def respond(req, heads, bdy): "Default FT response creation function" body_wrap = getattr(req, 'body_wrap', noop_body) @@ -478,13 +489,13 @@ def respond(req, heads, bdy): body = Body(body_wrap(*bw_args), *flat_xt(req.ftrs), **req.bodykw) return Html(Head(*heads, *flat_xt(req.hdrs)), body, **req.htmlkw) -# %% ../nbs/api/00_core.ipynb #b2007479 +# %% ../nbs/api/00_core.ipynb #a3674bc161b39a23 def is_full_page(req, resp): "Check if response should be rendered as full page or fragment" if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers -# %% ../nbs/api/00_core.ipynb #358badec +# %% ../nbs/api/00_core.ipynb #3ee9a5fb4b7929a3 def _part_resp(req, resp): "Partition response into HTTP headers, background tasks, and content" resp = flat_tuple(resp) @@ -501,7 +512,7 @@ def _part_resp(req, resp): if len(resp)==1: resp = resp[0] return resp,kw -# %% ../nbs/api/00_core.ipynb #da449a7b +# %% ../nbs/api/00_core.ipynb #c69f2a6c99eeeb1e def _canonical(req): if not req.app.canonical: return [] url = str(getattr(req, 'canonical', req.url)).replace('http://', 'https://', 1) @@ -517,12 +528,12 @@ def _xt_cts(req, resp): resp = respond(req, [*heads, *title, *_canonical(req)], bdy) return _to_xml(req, resp, indent=fh_cfg.indent) -# %% ../nbs/api/00_core.ipynb #775fb66b +# %% ../nbs/api/00_core.ipynb #3651675357475aa2 def _is_ft_resp(resp): "Check if response is a FastTag-compatible type" return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__') -# %% ../nbs/api/00_core.ipynb #968d9245 +# %% ../nbs/api/00_core.ipynb #74898954bcd20abe def _resp(req, resp, cls=empty, status_code=200): "Create appropriate HTTP response from request and response data" if resp is None: resp='' @@ -542,7 +553,7 @@ def _resp(req, resp, cls=empty, status_code=200): cls = HTMLResponse return cls(resp, status_code=status_code, **kw) -# %% ../nbs/api/00_core.ipynb #eac44461 +# %% ../nbs/api/00_core.ipynb #4b0745e3157bccbb class Redirect: "Use HTMX or Starlette RedirectResponse as required to redirect to `loc`" def __init__(self, loc): self.loc = loc @@ -550,13 +561,13 @@ def __response__(self, req): if 'hx-request' in req.headers: return HtmxResponseHeaders(redirect=self.loc) return RedirectResponse(self.loc, status_code=303) -# %% ../nbs/api/00_core.ipynb #1ba52822 +# %% ../nbs/api/00_core.ipynb #147cb69ae182351c async def _wrap_call(f, req, params): "Wrap function call with request parameter injection" wreq = await _wrap_req(req, params) return await _handle(f, **wreq) -# %% ../nbs/api/00_core.ipynb #b0d1cbbf +# %% ../nbs/api/00_core.ipynb #f7f626a00aad8476 # chkstyle: skip htmx_exts = { "morph": "https://cdn.jsdelivr.net/npm/idiomorph@0.7.3/dist/idiomorph-ext.min.js", @@ -576,7 +587,7 @@ async def _wrap_call(f, req, params): "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/ext/hx-multipart.min.js" } -# %% ../nbs/api/00_core.ipynb #60cb52ea +# %% ../nbs/api/00_core.ipynb #59076ca3b38a5fba htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.1/dist/htmx.min.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.13/fasthtml.js") @@ -585,7 +596,7 @@ async def _wrap_call(f, req, params): viewport = Meta(name="viewport", content="width=device-width, initial-scale=1, viewport-fit=cover") charset = Meta(charset="utf-8") -# %% ../nbs/api/00_core.ipynb #8bd78eeb +# %% ../nbs/api/00_core.ipynb #3f9e66d883079f28 def get_key(key=None, fname='.sesskey'): "Get session key from `key` param or read/create from file `fname`" if key: return key @@ -595,12 +606,12 @@ def get_key(key=None, fname='.sesskey'): fname.write_text(key) return key -# %% ../nbs/api/00_core.ipynb #042666e2 +# %% ../nbs/api/00_core.ipynb #7eb35d0263b7da5a def _list(o): "Wrap non-list item in a list, returning empty list if None" return [] if not o else list(o) if isinstance(o, (tuple,list)) else [o] -# %% ../nbs/api/00_core.ipynb #d276fc71 +# %% ../nbs/api/00_core.ipynb #87cbf1149a207d27 def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap): "Wrap exception handler with FastHTML request processing" async def _f(req, exc): @@ -612,7 +623,7 @@ async def _f(req, exc): return _resp(req, res, status_code=code) return _f -# %% ../nbs/api/00_core.ipynb #bc323fd4 +# %% ../nbs/api/00_core.ipynb #f772e4bd5cb60fe2 def qp(p:str, **kw) -> str: "Add parameters kw to path p" def _sub(m): @@ -624,12 +635,12 @@ def _sub(m): # encode query params return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '') -# %% ../nbs/api/00_core.ipynb #f86690c4 +# %% ../nbs/api/00_core.ipynb #7617757c40c72efe def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] if surreal: hdrs = [surrsrc,scopesrc] + hdrs - if htmx and htmx4: + if htmx and htmx4: # metaCharacter="-" makes htmx4 use dashes instead of colons meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"})) hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs @@ -637,7 +648,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs return [charset, viewport] + hdrs -# %% ../nbs/api/00_core.ipynb #2c5285ae +# %% ../nbs/api/00_core.ipynb #77869926479cdf46 cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*']) iframe_scr = Script(NotStr(""" @@ -663,7 +674,7 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): """)) -# %% ../nbs/api/00_core.ipynb #17ced9a3 +# %% ../nbs/api/00_core.ipynb #f127a504464b0eb5 class _LifespanCtx: def __init__(self, gen): self.gen = gen async def __aenter__(self): return await anext(self.gen) @@ -709,7 +720,7 @@ async def __call__(self, scope, receive, send): [(b'cookie', cookie.encode('latin-1'))]) await super().__call__(scope, receive, send) -# %% ../nbs/api/00_core.ipynb #3327a1e9 +# %% ../nbs/api/00_core.ipynb #c186654600ae903f class FastHTML(Starlette): "An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments" def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None, @@ -748,7 +759,7 @@ def _not_found(req, exc): return Response('404 Not Found', status_code=404) def on_event(self, event_type): return self.lifespan.on_event(event_type) -# %% ../nbs/api/00_core.ipynb #dce68049 +# %% ../nbs/api/00_core.ipynb #4d43cd70f3e6cd75 class HostRoute(Route): "Route with optional host-header constraint using Starlette's {param} pattern syntax" def __init__(self, path, endpoint, *, host=None, **kwargs): @@ -766,7 +777,7 @@ def __repr__(self) -> str: methods = sorted(self.methods or []) return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r}, methods={methods!r}, host={self.host!r})" -# %% ../nbs/api/00_core.ipynb #e0accf76 +# %% ../nbs/api/00_core.ipynb #d100258e169dbbc7 @patch def add_route(self:FastHTML, route): "Add or replace a route in the FastHTML app" @@ -778,10 +789,10 @@ def add_route(self:FastHTML, route): self.router.routes.sort(key=lambda r: not getattr(r, 'host', None)) -# %% ../nbs/api/00_core.ipynb #246bd8d1 +# %% ../nbs/api/00_core.ipynb #d7af2cc5decee07 all_meths = 'get post put delete patch head trace options'.split() -# %% ../nbs/api/00_core.ipynb #26b147ba +# %% ../nbs/api/00_core.ipynb #e025b784db90b0f0 @patch def _endp(self:FastHTML, f, body_wrap, before:Optional[Callable|tuple]=None): "Create endpoint wrapper with before/after middleware processing" @@ -810,7 +821,7 @@ async def _f(req): return _resp(req, resp, sig.return_annotation) return _f -# %% ../nbs/api/00_core.ipynb #3818575c +# %% ../nbs/api/00_core.ipynb #3213bbd6e68c6eca @patch def _add_ws(self:FastHTML, func, path, conn, disconn, name, middleware): "Add websocket route to FastHTML app" @@ -820,20 +831,20 @@ def _add_ws(self:FastHTML, func, path, conn, disconn, name, middleware): self.add_route(route) return func -# %% ../nbs/api/00_core.ipynb #669e76eb +# %% ../nbs/api/00_core.ipynb #b04007ab3b0564b7 @patch def ws(self:FastHTML, path:str, conn=None, disconn=None, name=None, middleware=None): "Add a websocket route at `path`" def f(func=noop): return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware) return f -# %% ../nbs/api/00_core.ipynb #j6ete5u68fo +# %% ../nbs/api/00_core.ipynb #f327ebcd11de3159 @patch def add_websocket_route(self:FastHTML, path, func, conn=None, disconn=None, name=None, middleware=None): "Add a websocket route at `path` (Starlette-compatible API)" return self._add_ws(func, path, conn, disconn, name=name, middleware=middleware) -# %% ../nbs/api/00_core.ipynb #919618c3 +# %% ../nbs/api/00_core.ipynb #73a6816c7fc2a48e def _mk_locfunc(f, p, app=None): "Create a location function wrapper with route path and to() method" class _lf: @@ -847,12 +858,12 @@ def __str__(self): return p return _lf() -# %% ../nbs/api/00_core.ipynb #d2ecc738 +# %% ../nbs/api/00_core.ipynb #106d9bbb8cb464d1 def nested_name(f): "Get name of function `f` using '_' to join nested function names" return f.__qualname__.replace('..', '_') -# %% ../nbs/api/00_core.ipynb #daafe4fc +# %% ../nbs/api/00_core.ipynb #bf1de7b1007ef27a @patch def _add_routes(self:FastHTML, cls, path, methods, name, include_in_schema, body_wrap, host=None, before:Optional[Callable|tuple]=None): "Add HTTP routes from methods on endpoint class `cls`" @@ -864,7 +875,7 @@ def _add_routes(self:FastHTML, cls, path, methods, name, include_in_schema, body if handler: self._add_route(handler, path, meth, name, include_in_schema, body_wrap, host=host, before=before) return lf -# %% ../nbs/api/00_core.ipynb #3710e48b +# %% ../nbs/api/00_core.ipynb #2c1c95dde3ab364b def _route_pn(func, path, name): "Infer route name/function name/path from an endpoint or endpoint class" fn = nested_name(func) @@ -889,7 +900,7 @@ def _add_route(self:FastHTML, func, path, methods, name, include_in_schema, body lf.__routename__ = n return lf -# %% ../nbs/api/00_core.ipynb #f5cb2c2b +# %% ../nbs/api/00_core.ipynb #1c4c8d49e989e7c7 @patch def route(self:FastHTML, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=None, host=None, before:Optional[Callable|tuple]=None): "Add a route at `path`" @@ -899,14 +910,14 @@ def f(func): for o in all_meths: setattr(FastHTML, o, partialmethod(FastHTML.route, methods=o)) -# %% ../nbs/api/00_core.ipynb #35c35a96 +# %% ../nbs/api/00_core.ipynb #bb214bd0b004e83c @patch def set_lifespan(self:FastHTML, value): "Set the lifespan context manager for the FastHTML app" if inspect.isasyncgenfunction(value): value = contextlib.asynccontextmanager(value) self.router.lifespan_context = value -# %% ../nbs/api/00_core.ipynb #3a348474 +# %% ../nbs/api/00_core.ipynb #306333c1e9460062 def serve( appname=None, # Name of the module app='app', # App instance to be served @@ -932,14 +943,14 @@ def serve( print('Link: '+ S.light_red.bold(link)) run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs) -# %% ../nbs/api/00_core.ipynb #91b08e9a +# %% ../nbs/api/00_core.ipynb #30c5cb0a53a38c92 def _spawn(n): "Run this script `n` times over, numbering each copy in `FH_PROCNUM`, and wait for them all" ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)] signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps]) for p in ps: p.wait() -# %% ../nbs/api/00_core.ipynb #c5220a0e +# %% ../nbs/api/00_core.ipynb #157c183cd8bd0475 async def _wait_disconnect(req): while (await req.receive())["type"] != "http.disconnect": pass @@ -954,7 +965,7 @@ async def until_disconnect(req, coro): watch.cancel() if not work.done(): work.cancel() -# %% ../nbs/api/00_core.ipynb #c5721d76 +# %% ../nbs/api/00_core.ipynb #63f31cbcd3a2cb7e def cancel_on_disconnect( f=None, # Async route handler to wrap *, @@ -968,7 +979,7 @@ async def _f(__req: Request, *args, **kw): return await (asyncio.wait_for(c, timeout) if timeout is not None else c) return splice_sig(_f, f) -# %% ../nbs/api/00_core.ipynb #8121968a +# %% ../nbs/api/00_core.ipynb #778e281e884f69a4 class Client: "A simple httpx ASGI client that doesn't require `async`" def __init__(self, app, url="http://testserver"): @@ -981,7 +992,7 @@ async def _request(): return await self.cli.request(method, url, **kwargs) for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o)) -# %% ../nbs/api/00_core.ipynb #d5223a9a +# %% ../nbs/api/00_core.ipynb #f1c11644b46e01d1 class RouteFuncs: "Attr-access store of named route functions (HTTP verb names excluded)" def __init__(self): super().__setattr__('_funcs', {}) @@ -992,7 +1003,7 @@ def __getattr__(self, name): except KeyError: raise AttributeError(f"No route named {name} found in route functions") def __dir__(self): return list(self._funcs.keys()) -# %% ../nbs/api/00_core.ipynb #0f08cb5e +# %% ../nbs/api/00_core.ipynb #890c6b36806499f6 class APIRouter: "Add routes to an app" def __init__(self, prefix:str|None=None, body_wrap=noop_body): @@ -1032,10 +1043,10 @@ def ws(self, path:str, conn=None, disconn=None, name=None, middleware=None): def f(func=noop): return self.wss.append((func, f"{self.prefix}{path}", conn, disconn, name, middleware)) return f -# %% ../nbs/api/00_core.ipynb #259a0f53 +# %% ../nbs/api/00_core.ipynb #c01860fe7517722d for o in all_meths: setattr(APIRouter, o, partialmethod(APIRouter.__call__, methods=o)) -# %% ../nbs/api/00_core.ipynb #e518de83 +# %% ../nbs/api/00_core.ipynb #6d41906f21e2e5bb def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None, secure=False, httponly=False, samesite="lax",): "Create a 'set-cookie' `HttpHeader`" cookie = cookies.SimpleCookie() @@ -1052,13 +1063,13 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None cookie_val = cookie.output(header="").strip() return HttpHeader("set-cookie", cookie_val) -# %% ../nbs/api/00_core.ipynb #8816f277 +# %% ../nbs/api/00_core.ipynb #acfb97d2b978356 def reg_re_param(m, s): "Register a Starlette URL convertor named `m` matching regex `s`" cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s) register_url_convertor(m, cls()) -# %% ../nbs/api/00_core.ipynb #e9535978 +# %% ../nbs/api/00_core.ipynb #b5aea8aaa5b024d2 # Starlette doesn't have the '?', so it chomps the whole remaining URL reg_re_param("path", ".*?") _static_exts = "ico gif jpg jpeg webm css js woff png svg mp4 webp ttf otf eot woff2 txt html map pdf zip tgz gz csv mp3 wav ogg flac aac doc docx xls xlsx ppt pptx epub mobi bmp tiff avi mov wmv mkv xml yaml yml rar 7z tar bz2 htm xhtml apk dmg exe msi swf iso".split() @@ -1070,14 +1081,14 @@ def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static') @self.get(f"{prefix}{{fname:path}}.{{ext:{exts}}}") async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}') -# %% ../nbs/api/00_core.ipynb #b31de65a +# %% ../nbs/api/00_core.ipynb #179c165cf2d70fa0 @patch def static_route(self:FastHTML, ext='', prefix='/', static_path='.'): "Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')" @self.get(f"{prefix}{{fname:path}}{ext}") async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}') -# %% ../nbs/api/00_core.ipynb #f63b7a03 +# %% ../nbs/api/00_core.ipynb #df8649e9ff8a4862 class StaticNoCache(StaticFiles): "StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use" def file_response(self, *args, **kwargs): @@ -1085,7 +1096,7 @@ def file_response(self, *args, **kwargs): resp.headers.setdefault("Cache-Control", "no-cache") return resp -# %% ../nbs/api/00_core.ipynb #74801f94 +# %% ../nbs/api/00_core.ipynb #b4b419bffadedf7c class StaticImmutable(StaticFiles): "Like `StaticNoCache`, but marks responses as cacheable forever: for versioned URLs such as `vurl` links or content-hashed filenames" def file_response(self, *args, **kwargs): @@ -1093,7 +1104,7 @@ def file_response(self, *args, **kwargs): resp.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") return resp -# %% ../nbs/api/00_core.ipynb #e38fd6c7 +# %% ../nbs/api/00_core.ipynb #f650d7d18b386925 @flexicache(mtime_policy(arg=0)) def _vhash(p): return hashlib.blake2b(p.read_bytes(), digest_size=4).hexdigest() @@ -1105,11 +1116,11 @@ def vurl(path, root='.'): return urlunsplit(u._replace(query=f'{q}v={_vhash(p)}')) -# %% ../nbs/api/00_core.ipynb #7189daf8 +# %% ../nbs/api/00_core.ipynb #e336fb23b6a4348 from functools import wraps from inspect import signature, isawaitable -# %% ../nbs/api/00_core.ipynb #7eed23b7 +# %% ../nbs/api/00_core.ipynb #fffed22d52dd5db8 def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Parameter.empty): "Add a parameter to a function's signature" sig = signature(f) @@ -1120,7 +1131,7 @@ def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Pa f.__annotations__[name] = typ return f -# %% ../nbs/api/00_core.ipynb #9e5a9e88 +# %% ../nbs/api/00_core.ipynb #2908db7385793cc5 class into: "Decorator to pass a route's return value into `func`, with keyword params added to the route signature" def __init__(self, func): @@ -1142,7 +1153,7 @@ async def _inner(*args, **kw): add_sig_param(_inner, k, anno, default=p.default) return _inner -# %% ../nbs/api/00_core.ipynb #1960d7ff +# %% ../nbs/api/00_core.ipynb #7f46d6e01ce2773c class MiddlewareBase: "Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`" async def __call__(self, scope, receive, send) -> None: @@ -1150,7 +1161,7 @@ async def __call__(self, scope, receive, send) -> None: await self._app(scope, receive, send) -# %% ../nbs/api/00_core.ipynb #83a20f93 +# %% ../nbs/api/00_core.ipynb #bd853c5a477ed976 class FtResponse: "Wrap an FT response with any Starlette `Response`" def __init__(self, content, status_code:int=200, headers=None, cls=HTMLResponse, media_type:str|None=None, background: BackgroundTask | None = None): @@ -1165,20 +1176,20 @@ def __response__(self, req): headers = {**(self.headers or {}), **httphdrs} return self.cls(cts, status_code=self.status_code, headers=headers, media_type=self.media_type, background=tasks) -# %% ../nbs/api/00_core.ipynb #9dc1025e +# %% ../nbs/api/00_core.ipynb #78bc2834a3ea6cda def unqid(seeded=False): "Random unique id, base64-encoded and prefixed with '_' so it's usable as an HTML id" id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4() res = b64encode(id4.bytes) return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-')) -# %% ../nbs/api/00_core.ipynb #5b67e014 +# %% ../nbs/api/00_core.ipynb #7c977335a4ba289b def _add_ids(s): if not isinstance(s, FT): return if not getattr(s, 'id', None): s.id = unqid() for c in s.children: _add_ids(c) -# %% ../nbs/api/00_core.ipynb #1f590f25 +# %% ../nbs/api/00_core.ipynb #1a6b71aa1ba2e789 @patch def setup_ws(app:FastHTML, f=noop): conns = {} @@ -1190,7 +1201,7 @@ async def send(s): app._send = send return send -# %% ../nbs/api/00_core.ipynb #a8a91edd +# %% ../nbs/api/00_core.ipynb #699ee6943e1c8ac6 devtools_loc = "/.well-known/appspecific/com.chrome.devtools.json" @patch @@ -1200,7 +1211,7 @@ def devtools_json(self:FastHTML, path=None, uuid=None): @self.route(devtools_loc) def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid)) -# %% ../nbs/api/00_core.ipynb #e27908c0 +# %% ../nbs/api/00_core.ipynb #75158856bbe1f5bf @patch def get_client(self:FastHTML, asink=False, **kw): "Get an httpx client with session cookes set from `**kw`" @@ -1212,7 +1223,7 @@ def get_client(self:FastHTML, asink=False, **kw): client.cookies.update({self.session_cookie: data.decode()}) return client -# %% ../nbs/api/00_core.ipynb #c845f437 +# %% ../nbs/api/00_core.ipynb #bb041161d82c7ec8 @patch def decode_session(self:FastHTML, cookie): "Decode a signed session cookie" @@ -1220,7 +1231,7 @@ def decode_session(self:FastHTML, cookie): unsigned = itsdangerous.TimestampSigner(self.secret_key).unsign(cookie, max_age=None) return loads(b64decode(unsigned).decode()) -# %% ../nbs/api/00_core.ipynb #49260e9b +# %% ../nbs/api/00_core.ipynb #8e3efceea8c5b4e9 @patch def get_testclient(self:FastHTML, **kw): "Get a Starlette `TestClient` with session cookies set from `**kw`"