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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions fasthtml/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
"""The fastest way to create an HTML app

Modules:

- `fasthtml.core`: The `FastHTML` subclass of `Starlette`."""

__version__ = "0.14.12"
from .core import *
62 changes: 57 additions & 5 deletions fasthtml/core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
"""The `FastHTML` subclass of `Starlette`.

Create an app with `app = FastHTML()`. The `@rt` decorator adds routes (`rt = app.route`), inferring what it can from the function:

@rt
def index(): ... # GET,POST /
@rt
def foo(): ... # GET,POST /foo
@rt('/hi')
def get(): ... # GET /hi (function named after a verb handles just that verb)
@rt('/cards/{id}')
class Cards: ... # class route group: each verb-named method (get, patch, ...) becomes a handler

The decorated function doubles as a URL builder, so links never need hardcoded paths:

A('More', href=foo.to(a=1)) # <a href="/foo?a=1"> `.to()` fills path params; the rest become query params.
Div('...', hx_get=foo) # route functions work directly as htmx attr values: hx-get="/foo"

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 `<head>`; the rest goes in `<body>`. 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:

req, ws the Request / WebSocket connection
sess session dict
app the FastHTML app
state app.state
scope ASGI scope
auth scope['auth']
htmx HtmxHeaders (parsed HX-* request headers)
body raw body text
data parsed form data as a dict
api ApiReturn (API vs browser dual responses)
send websocket send function
hdrs,ftrs the per-request copies of the app's `hdrs`/`ftrs` (head and footer content)
htmlkw the per-request copy of the app's `<html>` attrs
bodykw the per-request copy of the app's `<body>` attrs
resp the handler's response (injected into `after` functions)

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

See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above.

Docs: https://www.fastht.ml/docs/api/core.html.md"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/00_core.ipynb.
Expand Down Expand Up @@ -72,6 +112,7 @@ def snake2hyphens(s:str):

@dataclass
class HtmxHeaders:
"Parsed HX-* request headers"
boosted:str|None=None; current_url:str|None=None; history_restore_request:str|None=None; prompt:str|None=None
request:str|None=None; request_type:str|None=None; source:str|None=None; target:str|None=None;
trigger_name:str|None=None; trigger:str|None=None
Expand Down Expand Up @@ -120,7 +161,9 @@ def _form_arg(k, v, d):

# %% ../nbs/api/00_core.ipynb #5fc04751
@dataclass
class HttpHeader: k:str;v:str
class HttpHeader:
"A response header as a k,v pair"
k:str;v:str

# %% ../nbs/api/00_core.ipynb #94e18161
def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()
Expand Down Expand Up @@ -191,6 +234,7 @@ async def _from_body(conn, p, data):

# %% ../nbs/api/00_core.ipynb #88b6da3f
class ApiReturn:
"Request param: call with browser and API responses; returns kwargs as JSON if the client accepts `application/json`, else `norm`"
@classmethod
async def __from_request__(cls, data, req): return cls(req.headers.get('accept')=='application/json')
def __init__(self, isapi=False): self.isapi = isapi
Expand Down Expand Up @@ -278,6 +322,7 @@ def flat_xt(lst):

# %% ../nbs/api/00_core.ipynb #aacff5ac
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})'

Expand Down Expand Up @@ -326,6 +371,7 @@ def EventStream(s):

# %% ../nbs/api/00_core.ipynb #0dd0a414
def signal_shutdown():
"Return an `asyncio.Event` set when uvicorn receives a shutdown signal"
from uvicorn.main import Server
event = asyncio.Event()
@patch
Expand Down Expand Up @@ -614,6 +660,7 @@ def __aiter__(self): return self.gen
async def __anext__(self): return await self.gen.__anext__()

class Lifespan:
"Combine `on_startup`/`on_shutdown` callbacks and an optional lifespan generator into one context manager"
def __init__(self, startup=None, shutdown=None, ls=None):
startup,shutdown = listify(startup),listify(shutdown)
store_attr()
Expand All @@ -632,6 +679,7 @@ def on_event(self, event_type):

# %% ../nbs/api/00_core.ipynb #3327a1e9
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,
on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,
before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,
Expand Down Expand Up @@ -895,6 +943,7 @@ async def _request(): return await self.cli.request(method, url, **kwargs)

# %% ../nbs/api/00_core.ipynb #d5223a9a
class RouteFuncs:
"Attr-access store of named route functions (HTTP verb names excluded)"
def __init__(self): super().__setattr__('_funcs', {})
def __setattr__(self, name, value): self._funcs[name] = value
def __getattr__(self, name):
Expand Down Expand Up @@ -966,6 +1015,7 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None

# %% ../nbs/api/00_core.ipynb #8816f277
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())

Expand All @@ -990,6 +1040,7 @@ async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')

# %% ../nbs/api/00_core.ipynb #f63b7a03
class StaticNoCache(StaticFiles):
"StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use"
def file_response(self, *args, **kwargs):
resp = super().file_response(*args, **kwargs)
resp.headers.setdefault("Cache-Control", "no-cache")
Expand Down Expand Up @@ -1054,11 +1105,11 @@ async def _inner(*args, **kw):

# %% ../nbs/api/00_core.ipynb #1960d7ff
class MiddlewareBase:
"Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`"
async def __call__(self, scope, receive, send) -> None:
if scope["type"] not in ["http", "websocket"]:
await self._app(scope, receive, send)
return
return HTTPConnection(scope)
if scope["type"] in ("http", "websocket"): return HTTPConnection(scope)
await self._app(scope, receive, send)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I was fixing the class docstring I saw this could be 2 instead of 4 lines. But it's unrelated to the PR.


# %% ../nbs/api/00_core.ipynb #83a20f93
class FtResponse:
Expand All @@ -1077,6 +1128,7 @@ def __response__(self, req):

# %% ../nbs/api/00_core.ipynb #9dc1025e
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('+/', '_-'))
Expand Down
76 changes: 71 additions & 5 deletions nbs/api/00_core.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,53 @@
"> The `FastHTML` subclass of `Starlette`."
]
},
{
"cell_type": "markdown",
"id": "1f8f308f",
"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",
"\n",
" @rt\n",
" def index(): ... # GET,POST /\n",
" @rt\n",
" def foo(): ... # GET,POST /foo\n",
" @rt('/hi')\n",
" def get(): ... # GET /hi (function named after a verb handles just that verb)\n",
" @rt('/cards/{id}')\n",
" class Cards: ... # class route group: each verb-named method (get, patch, ...) becomes a handler\n",
"\n",
"The decorated function doubles as a URL builder, so links never need hardcoded paths:\n",
"\n",
" A('More', href=foo.to(a=1)) # <a href=\"/foo?a=1\"> `.to()` fills path params; the rest become query params.\n",
" Div('...', hx_get=foo) # route functions work directly as htmx attr values: hx-get=\"/foo\"\n",
"\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 `<head>`; the rest goes in `<body>`. 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",
"\n",
" req, ws the Request / WebSocket connection\n",
" sess session dict\n",
" app the FastHTML app\n",
" state app.state\n",
" scope ASGI scope\n",
" auth scope['auth']\n",
" htmx HtmxHeaders (parsed HX-* request headers)\n",
" body raw body text\n",
" data parsed form data as a dict\n",
" api ApiReturn (API vs browser dual responses)\n",
" send websocket send function\n",
" hdrs,ftrs the per-request copies of the app's `hdrs`/`ftrs` (head and footer content)\n",
" htmlkw the per-request copy of the app's `<html>` attrs\n",
" bodykw the per-request copy of the app's `<body>` attrs\n",
" resp the handler's response (injected into `after` functions)\n",
"\n",
"`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."
]
},
{
"cell_type": "markdown",
"id": "46e2e6e8",
Expand Down Expand Up @@ -224,6 +271,7 @@
"\n",
"@dataclass\n",
"class HtmxHeaders:\n",
" \"Parsed HX-* request headers\"\n",
" boosted:str|None=None; current_url:str|None=None; history_restore_request:str|None=None; prompt:str|None=None\n",
" request:str|None=None; request_type:str|None=None; source:str|None=None; target:str|None=None; \n",
" trigger_name:str|None=None; trigger:str|None=None\n",
Expand Down Expand Up @@ -404,7 +452,9 @@
"source": [
"#| export\n",
"@dataclass\n",
"class HttpHeader: k:str;v:str"
"class HttpHeader:\n",
" \"A response header as a k,v pair\"\n",
" k:str;v:str"
]
},
{
Expand Down Expand Up @@ -692,6 +742,7 @@
"source": [
"#| export\n",
"class ApiReturn:\n",
" \"Request param: call with browser and API responses; returns kwargs as JSON if the client accepts `application/json`, else `norm`\"\n",
" @classmethod\n",
" async def __from_request__(cls, data, req): return cls(req.headers.get('accept')=='application/json')\n",
" def __init__(self, isapi=False): self.isapi = isapi\n",
Expand Down Expand Up @@ -1126,6 +1177,7 @@
"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})'"
]
Expand Down Expand Up @@ -1264,6 +1316,7 @@
"source": [
"#| export\n",
"def signal_shutdown():\n",
" \"Return an `asyncio.Event` set when uvicorn receives a shutdown signal\"\n",
" from uvicorn.main import Server\n",
" event = asyncio.Event()\n",
" @patch\n",
Expand Down Expand Up @@ -1851,6 +1904,7 @@
" async def __anext__(self): return await self.gen.__anext__()\n",
"\n",
"class Lifespan:\n",
" \"Combine `on_startup`/`on_shutdown` callbacks and an optional lifespan generator into one context manager\"\n",
" def __init__(self, startup=None, shutdown=None, ls=None):\n",
" startup,shutdown = listify(startup),listify(shutdown)\n",
" store_attr()\n",
Expand All @@ -1877,6 +1931,7 @@
"source": [
"#| export\n",
"class FastHTML(Starlette):\n",
" \"An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments\"\n",
" def __init__(self, debug=False, routes=None, middleware=None, title: str = \"FastHTML page\", exception_handlers=None,\n",
" on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,\n",
" before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,\n",
Expand Down Expand Up @@ -3936,6 +3991,7 @@
"source": [
"#| export\n",
"class RouteFuncs:\n",
" \"Attr-access store of named route functions (HTTP verb names excluded)\"\n",
" def __init__(self): super().__setattr__('_funcs', {})\n",
" def __setattr__(self, name, value): self._funcs[name] = value\n",
" def __getattr__(self, name):\n",
Expand Down Expand Up @@ -4332,6 +4388,7 @@
"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())"
]
Expand Down Expand Up @@ -4417,6 +4474,7 @@
"source": [
"#| export\n",
"class StaticNoCache(StaticFiles):\n",
" \"StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use\"\n",
" def file_response(self, *args, **kwargs):\n",
" resp = super().file_response(*args, **kwargs)\n",
" resp.headers.setdefault(\"Cache-Control\", \"no-cache\")\n",
Expand Down Expand Up @@ -4651,11 +4709,11 @@
"source": [
"#| export\n",
"class MiddlewareBase:\n",
" \"Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`\"\n",
" async def __call__(self, scope, receive, send) -> None:\n",
" if scope[\"type\"] not in [\"http\", \"websocket\"]:\n",
" await self._app(scope, receive, send)\n",
" return\n",
" return HTTPConnection(scope)"
" if scope[\"type\"] in (\"http\", \"websocket\"): return HTTPConnection(scope)\n",
" await self._app(scope, receive, send)\n",
" "
]
},
{
Expand Down Expand Up @@ -4966,6 +5024,7 @@
"source": [
"#| export\n",
"def unqid(seeded=False):\n",
" \"Random unique id, base64-encoded and prefixed with '_' so it's usable as an HTML id\"\n",
" 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('+/', '_-'))"
Expand Down Expand Up @@ -5120,6 +5179,13 @@
"display_name": "python3",
"language": "python",
"name": "python3"
},
"solveit": {
"default_code": true,
"mode": "learning",
"use_thinking": true,
"use_tools": true,
"ver": 2
}
},
"nbformat": 4,
Expand Down
Loading