diff --git a/CHANGELOG.md b/CHANGELOG.md
index b182811a..1fc2e16e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
+## 0.14.14
+
+### New Features
+
+- Match hyphenated form/query names to underscore handler params and body fields, with exact names taking precedence ([#939](https://github.com/AnswerDotAI/fasthtml/issues/939))
+- Return 400 for malformed multipart and invalid JSON bodies; redirect to error path with `oauth_error` on OAuth token exchange failure ([#929](https://github.com/AnswerDotAI/fasthtml/issues/929))
+
+
## 0.14.13
### New Features
diff --git a/fasthtml/__init__.py b/fasthtml/__init__.py
index da9002e4..cb62b6db 100644
--- a/fasthtml/__init__.py
+++ b/fasthtml/__init__.py
@@ -4,5 +4,5 @@
- `fasthtml.core`: The `FastHTML` subclass of `Starlette`."""
-__version__ = "0.14.14"
+__version__ = "0.14.15"
from .core import *
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..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, 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, 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 #b7a14a169b3297a0
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 #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,51 +160,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 #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 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 #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", "")
@@ -223,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
@@ -235,10 +236,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 #eb20d63ddb8c94ef
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 +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:
@@ -256,9 +258,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 #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')]`"
+ 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 +281,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 = 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`
+ 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 = _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 #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 = []
@@ -326,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)
@@ -370,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
@@ -387,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
@@ -411,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):
@@ -443,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 = []
@@ -457,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)
@@ -471,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)
@@ -494,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)
@@ -510,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=''
@@ -535,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
@@ -543,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",
@@ -569,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")
@@ -578,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
@@ -588,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):
@@ -603,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):
@@ -615,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 = []
@@ -628,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("""
@@ -654,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)
@@ -681,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,
@@ -720,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):
@@ -738,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"
@@ -750,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"
@@ -782,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"
@@ -792,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:
@@ -819,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`"
@@ -836,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)
@@ -861,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`"
@@ -871,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
@@ -904,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
@@ -926,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
*,
@@ -940,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"):
@@ -953,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', {})
@@ -964,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):
@@ -1004,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()
@@ -1024,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()
@@ -1042,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):
@@ -1057,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):
@@ -1065,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()
@@ -1077,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)
@@ -1092,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):
@@ -1114,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:
@@ -1122,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):
@@ -1137,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 = {}
@@ -1162,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
@@ -1172,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`"
@@ -1184,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"
@@ -1192,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 1fa7a1c4..5909f72c 100644
--- a/nbs/api/00_core.ipynb
+++ b/nbs/api/00_core.ipynb
@@ -3,7 +3,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "fa505c58",
+ "id": "1ec26324bdbbb1c7",
"metadata": {},
"outputs": [],
"source": [
@@ -12,7 +12,7 @@
},
{
"cell_type": "markdown",
- "id": "2161d9b5",
+ "id": "dedbdfd343c1a82b",
"metadata": {},
"source": [
"# Core\n",
@@ -21,7 +21,7 @@
},
{
"cell_type": "markdown",
- "id": "1f8f308f",
+ "id": "6ee7a67ea3d7ac9c",
"metadata": {},
"source": [
"#| export\n",
@@ -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, 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",
@@ -68,7 +68,7 @@
},
{
"cell_type": "markdown",
- "id": "46e2e6e8",
+ "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/)."
@@ -76,7 +76,7 @@
},
{
"cell_type": "markdown",
- "id": "568b20cd",
+ "id": "db1b60be4448cee7",
"metadata": {},
"source": [
"## Imports and utils"
@@ -85,7 +85,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "23503b9e",
+ "id": "b7a14a169b3297a0",
"metadata": {},
"outputs": [],
"source": [
@@ -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",
@@ -121,7 +121,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "7f5d0a72",
+ "id": "478f6d160ede5f93",
"metadata": {},
"outputs": [],
"source": [
@@ -142,7 +142,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "19d3f2a7",
+ "id": "1585be45cd347d8d",
"metadata": {},
"outputs": [],
"source": [
@@ -154,7 +154,7 @@
},
{
"cell_type": "markdown",
- "id": "19ffa7e0",
+ "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."
@@ -163,7 +163,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "e68a76c9",
+ "id": "aecb7676f7cc2369",
"metadata": {},
"outputs": [],
"source": [
@@ -176,20 +176,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5331a3e7",
+ "id": "5d987b866e6f7454",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "datetime.datetime(2026, 3, 30, 14, 0)"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"parsed_date('2pm')"
]
@@ -197,20 +186,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c40c9071",
+ "id": "ebd59c6831b3cc75",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "True"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"isinstance(date.fromtimestamp(0), date)"
]
@@ -218,7 +196,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "7c820373",
+ "id": "8615d8ef40e50851",
"metadata": {},
"outputs": [],
"source": [
@@ -232,20 +210,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "442a5aac",
+ "id": "cac7bfc7b00a30be",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'Snake-Case'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"snake2hyphens(\"snake_case\")"
]
@@ -253,7 +220,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "25f3b8f8",
+ "id": "81ad0c31c5e60260",
"metadata": {},
"outputs": [],
"source": [
@@ -286,7 +253,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5b4b5d95",
+ "id": "4ddd28db661e1dba",
"metadata": {},
"outputs": [],
"source": [
@@ -301,20 +268,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "36e2cac0",
+ "id": "73f2932151861097",
"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"
- }
- ],
+ "outputs": [],
"source": [
"h = test_request(headers=Headers({'HX-Request':'1'}))\n",
"_get_htmx(h.headers)"
@@ -323,7 +279,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "1d53e8e7",
+ "id": "49368755a70898a0",
"metadata": {},
"outputs": [],
"source": [
@@ -333,7 +289,7 @@
},
{
"cell_type": "markdown",
- "id": "ef18f76b",
+ "id": "ca95b6a77993c054",
"metadata": {},
"source": [
"## Request and response"
@@ -342,7 +298,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5ab74473",
+ "id": "58685ffc8373ba6f",
"metadata": {},
"outputs": [],
"source": [
@@ -353,7 +309,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "88d12e68",
+ "id": "287965806fac1332",
"metadata": {},
"outputs": [],
"source": [
@@ -369,7 +325,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0afb520c",
+ "id": "e0ed6b4ddd1014c1",
"metadata": {},
"outputs": [],
"source": [
@@ -390,7 +346,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "95b1f5c9",
+ "id": "be37ef49e13b65d",
"metadata": {},
"outputs": [],
"source": [
@@ -411,7 +367,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c58ccadb",
+ "id": "3b51bbad3c65426",
"metadata": {},
"outputs": [],
"source": [
@@ -429,7 +385,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "59757d76",
+ "id": "3e260c670966d74c",
"metadata": {},
"outputs": [],
"source": [
@@ -442,7 +398,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5fc04751",
+ "id": "55342e74abb8a521",
"metadata": {},
"outputs": [],
"source": [
@@ -456,7 +412,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "94e18161",
+ "id": "4e1a41ff0d2006f8",
"metadata": {},
"outputs": [],
"source": [
@@ -470,20 +426,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "592d6c8e",
+ "id": "3ac6e8a48127c728",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'HX-Trigger-After-Settle'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"_to_htmx_header('trigger_after_settle')"
]
@@ -491,7 +436,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "f6a2e62e",
+ "id": "1bcf3e6bbeb82969",
"metadata": {},
"outputs": [],
"source": [
@@ -506,20 +451,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "e89857eb",
+ "id": "769922176702c962",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "HttpHeader(k='HX-Trigger-After-Settle', v='hi')"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"HtmxResponseHeaders(trigger_after_settle='hi')"
]
@@ -527,7 +461,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "56cc589f",
+ "id": "9efb05e73d5f5947",
"metadata": {},
"outputs": [],
"source": [
@@ -541,20 +475,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5453a684",
+ "id": "6843ecd419c1f999",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "{'a': int, 'b': str}"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"@dataclass\n",
"class Foo: a:int; b:str\n",
@@ -564,20 +487,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "bcad3a5a",
+ "id": "7c9b92ae09b4b1ec",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "{'x': str, 'y': str}"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"Point = namedtuple('Point', ['x', 'y'])\n",
"_annotations(Point)"
@@ -586,7 +498,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "cb4ed4aa",
+ "id": "e5828b2e7682c383",
"metadata": {},
"outputs": [],
"source": [
@@ -597,13 +509,14 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "ffdde66f",
+ "id": "a3058a8b3943fd8d",
"metadata": {},
"outputs": [],
"source": [
"#| export\n",
"def _formitem(form, k):\n",
- " \"Return single item `k` from `form` if len 1, otherwise return list\"\n",
+ " \"Return item `k`, accepting hyphens for underscores and unwrapping single-item form lists\"\n",
+ " if k not in form: k = {o.replace('-', '_'):o for o in form}.get(k, k)\n",
" 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"
@@ -612,7 +525,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5fe74444",
+ "id": "3c3233e509f6970e",
"metadata": {},
"outputs": [],
"source": [
@@ -626,7 +539,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "cf80ea34",
+ "id": "d5ea2e7798493538",
"metadata": {},
"outputs": [],
"source": [
@@ -640,7 +553,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8899bc0a",
+ "id": "da31ece677242cc6",
"metadata": {},
"outputs": [],
"source": [
@@ -651,7 +564,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "42c9cea0",
+ "id": "73947311e8867ddf",
"metadata": {},
"outputs": [],
"source": [
@@ -677,7 +590,7 @@
},
{
"cell_type": "markdown",
- "id": "437263a3",
+ "id": "8dc378792d632ae2",
"metadata": {},
"source": [
"Use the special `__from_request__` class method for custom creation:"
@@ -686,7 +599,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8235f17f",
+ "id": "28ed01aae9ac888b",
"metadata": {},
"outputs": [],
"source": [
@@ -700,7 +613,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "a4873835",
+ "id": "91fae17bd4925e81",
"metadata": {},
"outputs": [],
"source": [
@@ -714,7 +627,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0caedd04",
+ "id": "e80f45b5f2d1c328",
"metadata": {},
"outputs": [],
"source": [
@@ -730,6 +643,7 @@
" kwargs = await _find_ps(conn, data, conn.headers, ps)\n",
" return await maybe_await(ctor(**kwargs))\n",
" d = _annotations(anno)\n",
+ " 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)"
]
@@ -737,7 +651,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "88b6da3f",
+ "id": "eb20d63ddb8c94ef",
"metadata": {},
"outputs": [],
"source": [
@@ -753,7 +667,7 @@
},
{
"cell_type": "markdown",
- "id": "abfc3145",
+ "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."
@@ -762,7 +676,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "7cc39ba9",
+ "id": "61ac79f78f6492d6",
"metadata": {},
"outputs": [],
"source": [
@@ -778,17 +692,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "2a8b10f4",
+ "id": "178e01facf162f65",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "{'k': 'value1', 'v': 'value3'}\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"async def f(req):\n",
" def _f(p:HttpHeader): ...\n",
@@ -807,7 +713,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "301e06eb",
+ "id": "7da5229a3769ed",
"metadata": {},
"outputs": [],
"source": [
@@ -823,7 +729,7 @@
},
{
"cell_type": "markdown",
- "id": "18a9e366",
+ "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:"
@@ -832,7 +738,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "28bfbbc1",
+ "id": "c53e378d80aef7e5",
"metadata": {},
"outputs": [],
"source": [
@@ -844,7 +750,7 @@
},
{
"cell_type": "markdown",
- "id": "6103784b",
+ "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:"
@@ -853,7 +759,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "2aaa8fbe",
+ "id": "8e67736be407e8ad",
"metadata": {},
"outputs": [],
"source": [
@@ -867,20 +773,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "9246153f",
+ "id": "5a8f91f6d88d1ef4",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "\"['1', '2']\""
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"async def f(req): return Response(str(req.query_params.getlist('x')))\n",
"client = TestClient(Starlette(routes=[Route('/', f, methods=['GET'])]))\n",
@@ -890,13 +785,24 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5fa96e3a",
+ "id": "4d46f096ae3f0e05",
"metadata": {},
"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",
@@ -906,38 +812,37 @@
" 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(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",
- " 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",
+ " 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 = _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",
@@ -948,7 +853,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "bf42edad",
+ "id": "5849a37703c71c4b",
"metadata": {},
"outputs": [],
"source": [
@@ -961,20 +866,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0ac66d98",
+ "id": "d4f32846cec05208",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "Foo(d={'a': 1})"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"data = form2dict(await parse_form(req))\n",
"await _from_body(req, p, data)"
@@ -983,7 +877,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "090f1f0f",
+ "id": "b4a0893dd0cac34a",
"metadata": {},
"outputs": [],
"source": [
@@ -996,17 +890,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "26df5218",
+ "id": "29d868c520d765ea",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "ok body=19\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"def g(test_val:int=0, item:HttpHeader=None, htmx:HtmxHeaders=None, api:ApiReturn=None): ...\n",
"\n",
@@ -1024,17 +910,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "bf945ee8",
+ "id": "c0724d784ab594d1",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3'), 'state': }\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"def g(req, this:Starlette, a:str, b:HttpHeader, state): ...\n",
"\n",
@@ -1050,17 +928,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "a3ded5ec",
+ "id": "d0f92af0fbc7c0fd",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3')}\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"def g(req, this:Starlette, a:str, b:HttpHeader): ...\n",
"\n",
@@ -1076,20 +946,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0e7601fe",
+ "id": "b396c52f3346caa7",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "\"Bar(accept='application/json')\""
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"class Bar(BasicRepr):\n",
" @classmethod\n",
@@ -1108,7 +967,7 @@
},
{
"cell_type": "markdown",
- "id": "34066c89",
+ "id": "78335a952aea05ad",
"metadata": {},
"source": [
"**Missing Request Params**"
@@ -1116,7 +975,7 @@
},
{
"cell_type": "markdown",
- "id": "06259883",
+ "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."
@@ -1125,17 +984,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c369a89c",
+ "id": "b3db13a8d0036d32",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "{'req': , 'this': , 'a': ''}\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"def g(req, this:Starlette, a:str=''): ...\n",
"\n",
@@ -1150,7 +1001,7 @@
},
{
"cell_type": "markdown",
- "id": "35b68f4f",
+ "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`."
@@ -1159,17 +1010,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b33b43a7",
+ "id": "fb7d7837e7ff6ba2",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Missing required field: a\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"def g(req, this:Starlette, a:str): ...\n",
"\n",
@@ -1185,7 +1028,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "7a661bfa",
+ "id": "4b264f3ec9a80e3a",
"metadata": {},
"outputs": [],
"source": [
@@ -1203,7 +1046,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "2ee5adf1",
+ "id": "d5ee5b99fc8fc445",
"metadata": {},
"outputs": [],
"source": [
@@ -1215,7 +1058,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "aacff5ac",
+ "id": "4b57d3d58af69e51",
"metadata": {},
"outputs": [],
"source": [
@@ -1229,7 +1072,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "78c3c357",
+ "id": "de37dcd7b5641749",
"metadata": {},
"outputs": [],
"source": [
@@ -1241,7 +1084,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c995850c",
+ "id": "868b208fabcce9b6",
"metadata": {},
"outputs": [],
"source": [
@@ -1251,7 +1094,7 @@
},
{
"cell_type": "markdown",
- "id": "079a3215",
+ "id": "919baa8e3eb29d22",
"metadata": {},
"source": [
"## Websockets / SSE"
@@ -1260,7 +1103,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "ad0f0e87",
+ "id": "761597782f7f9df6",
"metadata": {},
"outputs": [],
"source": [
@@ -1274,7 +1117,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "dcc15129",
+ "id": "7a3be2b2f30770d7",
"metadata": {},
"outputs": [],
"source": [
@@ -1310,7 +1153,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "983bcfe2",
+ "id": "a2237401400886a2",
"metadata": {},
"outputs": [],
"source": [
@@ -1326,7 +1169,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "bda2e425",
+ "id": "a73456aac0e74ecb",
"metadata": {},
"outputs": [],
"source": [
@@ -1341,7 +1184,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5b0e7677",
+ "id": "ddc64ff604c831b8",
"metadata": {},
"outputs": [],
"source": [
@@ -1354,7 +1197,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0dd0a414",
+ "id": "2e11648d17d6d6f5",
"metadata": {},
"outputs": [],
"source": [
@@ -1373,7 +1216,7 @@
},
{
"cell_type": "markdown",
- "id": "496cd78f",
+ "id": "fc2f9df0b33fc305",
"metadata": {},
"source": [
"## Routing and application"
@@ -1382,7 +1225,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "03f4c639",
+ "id": "f660c44ff209b90c",
"metadata": {},
"outputs": [],
"source": [
@@ -1395,7 +1238,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "bdb2ac14",
+ "id": "3e45f287f5af27c5",
"metadata": {},
"outputs": [],
"source": [
@@ -1409,7 +1252,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b80ce139",
+ "id": "580afa399f3bd333",
"metadata": {},
"outputs": [],
"source": [
@@ -1420,7 +1263,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "a27adc1e",
+ "id": "2bb9a1ff5768ba68",
"metadata": {},
"outputs": [],
"source": [
@@ -1438,7 +1281,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "46614165",
+ "id": "d99b78e3a99c3ec0",
"metadata": {},
"outputs": [],
"source": [
@@ -1452,7 +1295,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c1707d59",
+ "id": "c4d78aa8d576e6cc",
"metadata": {},
"outputs": [],
"source": [
@@ -1486,7 +1329,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "f1e3ed2d",
+ "id": "f2f473dc3aa8e232",
"metadata": {},
"outputs": [],
"source": [
@@ -1497,7 +1340,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "a407dc0e",
+ "id": "ce31ff717114b811",
"metadata": {},
"outputs": [],
"source": [
@@ -1516,7 +1359,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "d36402e6",
+ "id": "aaf0c02fc0142c20",
"metadata": {},
"outputs": [],
"source": [
@@ -1529,7 +1372,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "7f49728d",
+ "id": "6df817363734849b",
"metadata": {},
"outputs": [],
"source": [
@@ -1545,7 +1388,7 @@
},
{
"cell_type": "markdown",
- "id": "cfae0a56",
+ "id": "2a6054f74f4c9f90",
"metadata": {},
"source": [
"Render fragment if `HX-Request` header is _present_ and\n",
@@ -1555,7 +1398,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b2007479",
+ "id": "2f96c17575a317a",
"metadata": {},
"outputs": [],
"source": [
@@ -1569,7 +1412,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "358badec",
+ "id": "99a2f618ca8e26e9",
"metadata": {},
"outputs": [],
"source": [
@@ -1594,7 +1437,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "da449a7b",
+ "id": "d18dc765f6c377c3",
"metadata": {},
"outputs": [],
"source": [
@@ -1618,7 +1461,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "775fb66b",
+ "id": "5afa1b8942912373",
"metadata": {},
"outputs": [],
"source": [
@@ -1631,7 +1474,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "968d9245",
+ "id": "67c986c99e7a6231",
"metadata": {},
"outputs": [],
"source": [
@@ -1659,7 +1502,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "eac44461",
+ "id": "15e507d2acd3232e",
"metadata": {},
"outputs": [],
"source": [
@@ -1675,7 +1518,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "1ba52822",
+ "id": "92a6fdb45add90f0",
"metadata": {},
"outputs": [],
"source": [
@@ -1689,7 +1532,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b0d1cbbf",
+ "id": "49c3d12b947fa377",
"metadata": {},
"outputs": [],
"source": [
@@ -1716,7 +1559,7 @@
},
{
"cell_type": "markdown",
- "id": "3531273d",
+ "id": "cf08377dacfc7dea",
"metadata": {},
"source": [
"The FastHTML `exts` param supports the following:"
@@ -1725,17 +1568,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "deffbaaa",
+ "id": "62a280661c939ed3",
"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"
- ]
- }
- ],
+ "outputs": [],
"source": [
"print(' '.join(htmx_exts))"
]
@@ -1743,7 +1578,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "60cb52ea",
+ "id": "b31e24130b390101",
"metadata": {},
"outputs": [],
"source": [
@@ -1760,7 +1595,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8bd78eeb",
+ "id": "af5fa00cfaef7a8b",
"metadata": {},
"outputs": [],
"source": [
@@ -1778,7 +1613,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "042666e2",
+ "id": "17ff3158cc805754",
"metadata": {},
"outputs": [],
"source": [
@@ -1791,7 +1626,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "d276fc71",
+ "id": "d2a4061192d883e8",
"metadata": {},
"outputs": [],
"source": [
@@ -1809,7 +1644,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "bc323fd4",
+ "id": "f0ddf2219c879e3b",
"metadata": {},
"outputs": [],
"source": [
@@ -1828,7 +1663,7 @@
},
{
"cell_type": "markdown",
- "id": "e51e2dc3",
+ "id": "e117438a658aa761",
"metadata": {},
"source": [
"`qp` adds query parameters to route path strings"
@@ -1837,7 +1672,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "ff82dc78",
+ "id": "12f6b4803592427c",
"metadata": {},
"outputs": [],
"source": [
@@ -1847,7 +1682,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c0836a8f",
+ "id": "3e8752f87bea162b",
"metadata": {},
"outputs": [],
"source": [
@@ -1857,7 +1692,7 @@
},
{
"cell_type": "markdown",
- "id": "4740272f",
+ "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."
@@ -1866,7 +1701,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "50ebb1ee",
+ "id": "19c390817bbfbdaf",
"metadata": {},
"outputs": [],
"source": [
@@ -1878,7 +1713,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "f86690c4",
+ "id": "8add43015a5b4c0c",
"metadata": {},
"outputs": [],
"source": [
@@ -1899,7 +1734,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "2c5285ae",
+ "id": "54f3bb368193da5c",
"metadata": {},
"outputs": [],
"source": [
@@ -1932,7 +1767,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "17ced9a3",
+ "id": "876c9c8cb63f2cf2",
"metadata": {},
"outputs": [],
"source": [
@@ -1967,7 +1802,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "3327a1e9",
+ "id": "d8d78a64be4bf27",
"metadata": {},
"outputs": [],
"source": [
@@ -2013,7 +1848,7 @@
},
{
"cell_type": "markdown",
- "id": "c7d4052c",
+ "id": "76d26b7b94050a73",
"metadata": {},
"source": [
"### Routes"
@@ -2022,7 +1857,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "dce68049",
+ "id": "b000f60adf333122",
"metadata": {},
"outputs": [],
"source": [
@@ -2048,17 +1883,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b8823cfe",
+ "id": "d673eda936d3ac03",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "all passed\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"app = FastHTML()\n",
"app.router.routes = []\n",
@@ -2076,17 +1903,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "3da46acd",
+ "id": "c2ba4ddbb35095f",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "all passed\n"
- ]
- }
- ],
+ "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",
@@ -2100,7 +1919,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "e0accf76",
+ "id": "a1583c0bcb0420f2",
"metadata": {},
"outputs": [],
"source": [
@@ -2119,7 +1938,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "246bd8d1",
+ "id": "4917c9f35ccd9f96",
"metadata": {},
"outputs": [],
"source": [
@@ -2130,7 +1949,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "26b147ba",
+ "id": "46ca67a58ad666b9",
"metadata": {},
"outputs": [],
"source": [
@@ -2167,7 +1986,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "3818575c",
+ "id": "a0744b04d81228d6",
"metadata": {},
"outputs": [],
"source": [
@@ -2185,7 +2004,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "669e76eb",
+ "id": "2fc600349ae25291",
"metadata": {},
"outputs": [],
"source": [
@@ -2200,7 +2019,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "j6ete5u68fo",
+ "id": "17f6217b9e528764",
"metadata": {},
"outputs": [],
"source": [
@@ -2214,7 +2033,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "919618c3",
+ "id": "48e0a2670e869b6b",
"metadata": {},
"outputs": [],
"source": [
@@ -2236,7 +2055,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "d2ecc738",
+ "id": "a0c09d4bddc479a",
"metadata": {},
"outputs": [],
"source": [
@@ -2249,7 +2068,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c0f13ece",
+ "id": "1cadfcaba56484f5",
"metadata": {},
"outputs": [],
"source": [
@@ -2261,20 +2080,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b218f738",
+ "id": "14dc3b2b623ea0f5",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'f_g'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"func = f()\n",
"nested_name(func)"
@@ -2283,7 +2091,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "daafe4fc",
+ "id": "8f0115f7adff7835",
"metadata": {},
"outputs": [],
"source": [
@@ -2303,7 +2111,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "3710e48b",
+ "id": "329e78f5bbc6ded1",
"metadata": {},
"outputs": [],
"source": [
@@ -2336,7 +2144,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "f5cb2c2b",
+ "id": "ebf0ff44a915aa0c",
"metadata": {},
"outputs": [],
"source": [
@@ -2354,20 +2162,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "e6ee3a86",
+ "id": "d7e65f487811260a",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'/foo?a=bar&b=1&b=2'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"app = FastHTML()\n",
"@app.get\n",
@@ -2379,20 +2176,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "9b9f1f03",
+ "id": "256527283aa9a22c",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'/foo/bar?b=1&b=2'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"@app.get('/foo/{a}')\n",
"def foo(a:str, b:list[int]): ...\n",
@@ -2402,7 +2188,7 @@
},
{
"cell_type": "markdown",
- "id": "4ab96dc2",
+ "id": "8e67c9cd986ebcad",
"metadata": {},
"source": [
"### Utils"
@@ -2411,7 +2197,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "35c35a96",
+ "id": "94d6f8f81d93aa5e",
"metadata": {},
"outputs": [],
"source": [
@@ -2426,7 +2212,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "3a348474",
+ "id": "b5e55224f5a20d18",
"metadata": {},
"outputs": [],
"source": [
@@ -2459,7 +2245,7 @@
},
{
"cell_type": "markdown",
- "id": "428b0e9c",
+ "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."
@@ -2468,7 +2254,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "91b08e9a",
+ "id": "ddc667c24b6df7dc",
"metadata": {},
"outputs": [],
"source": [
@@ -2482,7 +2268,7 @@
},
{
"cell_type": "markdown",
- "id": "1129768b",
+ "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."
@@ -2491,7 +2277,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c5220a0e",
+ "id": "78dc3bffc42b5d1",
"metadata": {},
"outputs": [],
"source": [
@@ -2513,7 +2299,7 @@
},
{
"cell_type": "markdown",
- "id": "8c6a8cb8",
+ "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."
@@ -2522,7 +2308,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c5721d76",
+ "id": "3ddda418760bb472",
"metadata": {},
"outputs": [],
"source": [
@@ -2544,7 +2330,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8121968a",
+ "id": "33e9385190411631",
"metadata": {},
"outputs": [],
"source": [
@@ -2565,20 +2351,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b163c933",
+ "id": "201775479e8c5e4d",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'test'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"app = FastHTML(routes=[Route('/', lambda _: Response('test'))])\n",
"cli = Client(app)\n",
@@ -2588,7 +2363,7 @@
},
{
"cell_type": "markdown",
- "id": "efc8e6db",
+ "id": "a935603ded43699",
"metadata": {},
"source": [
"Note that you can also use Starlette's `TestClient` instead of FastHTML's `Client`. They should be largely interchangable."
@@ -2596,7 +2371,7 @@
},
{
"cell_type": "markdown",
- "id": "2d2633dd",
+ "id": "6b039d58b7ad938c",
"metadata": {},
"source": [
"## FastHTML Tests"
@@ -2605,7 +2380,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "9abc3781",
+ "id": "7447e238bc623b35",
"metadata": {},
"outputs": [],
"source": [
@@ -2615,7 +2390,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "645d8d95",
+ "id": "1bf62de2b22c7f0c",
"metadata": {},
"outputs": [],
"source": [
@@ -2625,27 +2400,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "421262a8",
+ "id": "d90ee04d8045202b",
"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"
- }
- ],
+ "outputs": [],
"source": [
"app,cli,rt = get_cli(FastHTML(title=\"My Custom Title\"))\n",
"@app.get\n",
@@ -2662,17 +2419,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8836b62c",
+ "id": "47ef7be9d158140f",
"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"
- ]
- }
- ],
+ "outputs": [],
"source": [
"@rt(host='test.foo')\n",
"def foo(): return Div(\"Goodbye World\")\n",
@@ -2686,20 +2435,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "377012de",
+ "id": "b943b2cdaf919cb4",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'/foo?param=value'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"response = cli.get('/foo')\n",
"assert 'My Custom Title' in response.text\n",
@@ -2710,7 +2448,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "2ebd6270",
+ "id": "92ccb75904de8458",
"metadata": {},
"outputs": [],
"source": [
@@ -2726,7 +2464,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "274653c2",
+ "id": "20cc2bf645b7006d",
"metadata": {},
"outputs": [],
"source": [
@@ -2738,7 +2476,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b8a77cde",
+ "id": "944a5a468d63cacd",
"metadata": {},
"outputs": [],
"source": [
@@ -2760,7 +2498,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "ac7b527d",
+ "id": "b1237fb2b56936f7",
"metadata": {},
"outputs": [],
"source": [
@@ -2775,20 +2513,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8a6ed879",
+ "id": "8c9af7970bef9521",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'Hi there'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"@rt(\"/hi\")\n",
"def get(): return 'Hi there'\n",
@@ -2800,20 +2527,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "a8e723fc",
+ "id": "c61831785468d85",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'Postal'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"@rt(\"/hi\")\n",
"def post(): return 'Postal'\n",
@@ -2824,20 +2540,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "274666db",
+ "id": "5b9b648519a9841",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'testserver'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"@app.get(\"/hostie\")\n",
"def show_host(req): return req.headers['host']\n",
@@ -2848,17 +2553,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "72428702",
+ "id": "c8c78e969d888a8",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Message text was: Hi! with session bar, from client: Address(host='testclient', port=50000)\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"@app.get(\"/setsess\")\n",
"def set_sess(session):\n",
@@ -2879,20 +2576,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "36292bf3",
+ "id": "5ae370e0b73ee395",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'a yoyo'"
- ]
- },
- "execution_count": null,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"@rt\n",
"def yoyo(): return 'a yoyo'\n",
@@ -2903,27 +2589,9 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0d813cd0",
+ "id": "6910ecf551279150",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\n",
- "\n",
- "