简明参考

一份信息密集的指南,向 LLM 展示如何使用 MonsterUI 和 Fastlite 创建 FastHTML 应用

关于 FastHTML

from fasthtml.common import *

FastHTML 是一个 Python 库,它将 Starlette、Uvicorn、HTMX 和 fastcore 的 FT “FastTags” 整合到一个库中,用于创建服务器渲染的超媒体应用程序。FastHTML 类本身继承自 Starlette,并增加了基于装饰器的路由以及许多附加功能、Beforeware(前置件)、自动将 FT 渲染为 HTML 等等。

编写 FastHTML 应用时需要记住的事项

  • 兼容 FastAPI 语法;FastHTML 适用于 HTML 优先的应用,而不是 API 服务(尽管它也可以实现 API)。
  • FastHTML 包含对 Pico CSS 和 fastlite sqlite 库的支持,但两者都是可选的;sqlalchemy 可以直接使用,也可以通过 fastsql 库使用,并且可以使用任何 CSS 框架。MonsterUI 是一个功能更丰富的 FastHTML 优先的组件框架,其功能与 shadcn 类似。
  • FastHTML 兼容原生 JS Web 组件和任何原生 JS 库,但不兼容 React、Vue 或 Svelte。
  • 使用 serve() 来运行 uvicorn(不需要 if __name__ == "__main__",因为它是自动的)。
  • 当响应需要标题时,使用 Titled;请注意,它已经将子元素包裹在 Container 中,并且已经包含了 meta 标题和 H1 元素。

最小化应用

这里的代码示例使用了 fast.ai 风格:偏好三元运算符、单行文档字符串、最小化垂直空间等。(通常 fast.ai 风格很少使用或不使用注释,但此处添加注释是为了文档说明。)

一个最小化的 FastHTML 应用看起来像这样:

# Meta-package with all key symbols from FastHTML and Starlette. Import it like this at the start of every FastHTML app.
from fasthtml.common import *
# The FastHTML app object and shortcut to `app.route`
app,rt = fast_app()

# Enums constrain the values accepted for a route parameter
name = str_enum('names', 'Alice', 'Bev', 'Charlie')

# Passing a path to `rt` is optional. If not passed (recommended), the function name is the route ('/foo')
# Both GET and POST HTTP methods are handled by default
# Type-annotated params are passed as query params (recommended) unless a path param is defined (which it isn't here)
@rt
def foo(nm: name):
    # `Title` and `P` here are FastTags: direct m-expression mappings of HTML tags to Python functions with positional and named parameters. All standard HTML tags are included in the common wildcard import.
    # When a tuple is returned, this returns concatenated HTML partials. HTMX by default will use a title HTML partial to set the current page name. HEAD tags (e.g. Meta, Link, etc) in the returned tuple are automatically placed in HEAD; everything else is placed in BODY.
    # FastHTML will automatically return a complete HTML document with appropriate headers if a normal HTTP request is received. For an HTMX request, however, just the partials are returned.
    return Title("FastHTML"), H1("My web app"), P(f"Hello, {name}!")
# By default `serve` runs uvicorn on port 5001. Never write `if __name__ == "__main__"` since `serve` checks it internally.
serve()

运行此 Web 应用:

python main.py  # access via localhost:5001

FastTags(又名 FT 组件或 FT)

FT 是 M-表达式加上简单的语法糖。位置参数映射到子元素。命名参数映射到属性。对于 Python 的保留字必须使用别名。

tags = Title("FastHTML"), H1("My web app"), P(f"Let's do this!", cls="myclass")
tags
(title(('FastHTML',),{}),
 h1(('My web app',),{}),
 p(("Let's do this!",),{'class': 'myclass'}))

此示例展示了 FT 处理属性的关键方面:

Label(
    "Choose an option", 
    Select(
        Option("one", value="1", selected=True),  # True renders just the attribute name
        Option("two", value=2, selected=False),   # Non-string values are converted to strings. False omits the attribute entirely
        cls="selector", id="counter",             # 'cls' becomes 'class'
        **{'@click':"alert('Clicked');"},         # Dict unpacking for attributes with special chars
    ),
    _for="counter",                               # '_for' becomes 'for' (can also use 'fr')
)

定义了 __ft__ 的类将使用该方法进行渲染。

class FtTest:
    def __ft__(self): return P('test')
    
to_xml(FtTest())
'<p>test</p>\n'

您可以通过从 fasthtml.components 导入新组件来创建新的 FT。如果该 FT 在模块中不存在,FastHTML 将会创建它。

from fasthtml.components import Some_never_before_used_tag

Some_never_before_used_tag()
<some-never-before-used-tag></some-never-before-used-tag>

FT 可以通过将它们定义为函数来组合。

def Hero(title, statement): return Div(H1(title),P(statement), cls="hero")
to_xml(Hero("Hello World", "This is a hero statement"))
'<div class="hero">\n  <h1>Hello World</h1>\n  <p>This is a hero statement</p>\n</div>\n'

在处理响应时,FastHTML 会自动使用 to_xml 函数渲染 FT。

to_xml(tags)
'<title>FastHTML</title>\n<h1>My web app</h1>\n<p class="myclass">Let&#x27;s do this!</p>\n'

JS

Script 函数允许您包含 JavaScript。您可以使用 Python 生成部分 JS 或 JSON,如下所示:

# In future snippets this import will not be shown, but is required
from fasthtml.common import * 
app,rt = fast_app(hdrs=[Script(src="https://cdn.plot.ly/plotly-2.32.0.min.js")])
# `index` is a special function name which maps to the `/` route. 
@rt
def index():
    data = {'somedata':'fill me in…'}
    # `Titled` returns a title tag and an h1 tag with the 1st param, with remaining params as children in a `Main` parent.
    return Titled("Chart Demo", Div(id="myDiv"), Script(f"var data = {data}; Plotly.newPlot('myDiv', data);"))
# In future snippets `serve() will not be shown, but is required
serve()

尽可能优先使用 Python 而不是 JS。绝不使用 React 或 shadcn。

fast_app 响应头

# In future snippets we'll skip showing the `fast_app` call if it has no params
app, rt = fast_app(
    pico=False, # The Pico CSS framework is included by default, so pass `False` to disable it if needed. No other CSS frameworks are included.
    # These are added to the `head` part of the page for non-HTMX requests.
    hdrs=(
        Link(rel='stylesheet', href='assets/normalize.min.css', type='text/css'),
        Link(rel='stylesheet', href='assets/sakura.css', type='text/css'),
        Style("p {color: red;}"),
        # `MarkdownJS` and `HighlightJS` are available via concise functions
        MarkdownJS(), HighlightJS(langs=['python', 'javascript', 'html', 'css']),
        # by default, all standard static extensions are served statically from the web app dir,
        #   which can be modified using e.g `static_path='public'`
        )
)

@rt
def index(req): return Titled("Markdown rendering example",
                              # This will be client-side rendered to HTML with highlight-js
                              Div("*hi* there",cls="marked"),
                              # This will be syntax highlighted
                              Pre(Code("def foo(): pass")))

响应

路由可以返回多种类型:

  1. FastTags 或 FastTags 元组(自动渲染为 HTML)
  2. 标准的 Starlette 响应(直接使用)
  3. 可 JSON 序列化的类型(在纯文本响应中作为 JSON 返回)
@rt("/{fname:path}.{ext:static}")
async def serve_static_file(fname:str, ext:str): return FileResponse(f'public/{fname}.{ext}')

app, rt = fast_app(hdrs=(MarkdownJS(), HighlightJS(langs=['python', 'javascript'])))
@rt
def index(): 
    return Titled("Example",
                  Div("*markdown* here", cls="marked"),
                  Pre(Code("def foo(): pass")))

路由函数可以用在 hrefaction 等属性中,并将被转换为路径。使用 .to() 来生成带有查询参数的路径。

@rt
def profile(email:str): return fill_form(profile_form, profiles[email])

profile_form = Form(action=profile)(
    Label("Email", Input(name="email")),
    Button("Save", type="submit")
)

user_profile_path = profile.to(email="[email protected]")  # '/profile?email=user%40example.com'
from dataclasses import dataclass

app,rt = fast_app()

当路由处理函数被用作 fasttag 属性(例如 hrefhx_getaction)时,它会被转换为该路由的路径。fill_form 用于将对象的匹配属性复制到同名的表单字段中。

@dataclass
class Profile: email:str; phone:str; age:int
email = '[email protected]'
profiles = {email: Profile(email=email, phone='123456789', age=5)}
@rt
def profile(email:str): return fill_form(profile_form, profiles[email])

profile_form = Form(method="post", action=profile)(
        Fieldset(
            Label('Email', Input(name="email")),
            Label("Phone", Input(name="phone")),
            Label("Age", Input(name="age"))),
        Button("Save", type="submit"))

测试

我们可以使用 TestClient 进行测试。

from starlette.testclient import TestClient
path = "/[email protected]"
client = TestClient(app)
htmx_req = {'HX-Request':'1'}
print(client.get(path, headers=htmx_req).text)
<form enctype="multipart/form-data" method="post" action="/profile"><fieldset><label>Email       <input name="email" value="[email protected]">
</label><label>Phone       <input name="phone" value="123456789">
</label><label>Age       <input name="age" value="5">
</label></fieldset><button type="submit">Save</button></form>

表单处理和数据绑定

当数据类(dataclass)、命名元组(namedtuple)等用作类型注解时,表单主体将自动解包到匹配的属性名称中。

@rt
def edit_profile(profile: Profile):
    profiles[email]=profile
    return RedirectResponse(url=path)

new_data = dict(email='[email protected]', phone='7654321', age=25)
print(client.post("/edit_profile", data=new_data, headers=htmx_req).text)
<form enctype="multipart/form-data" method="post" action="/profile"><fieldset><label>Email       <input name="email" value="[email protected]">
</label><label>Phone       <input name="phone" value="7654321">
</label><label>Age       <input name="age" value="25">
</label></fieldset><button type="submit">Save</button></form>

fasttag 渲染规则

在元组或 fasttag 子元素中渲染子元素的一般规则是: - 将调用 __ft__ 方法(对于默认组件如 PH2 等,或者如果您定义了自己的组件) - 如果传递字符串,它将被转义 - 对于其他 Python 对象,将调用 str()

如果你想直接将普通 HTML 标签包含到例如 Div() 中,它们默认会被转义(作为防止代码注入的安全措施)。这可以通过使用 Safe(...) 来避免,例如要显示一个数据帧,请使用 Div(NotStr(df.to_html()))

异常

FastHTML 允许自定义异常处理程序。

def not_found(req, exc): return Titled("404: I don't exist!")
exception_handlers = {404: not_found}
app, rt = fast_app(exception_handlers=exception_handlers)

Cookie

我们可以使用 cookie() 函数设置 cookie。

@rt
def setcook(): return P(f'Set'), cookie('mycookie', 'foobar')
print(client.get('/setcook', headers=htmx_req).text)
 <p>Set</p>
@rt
def getcook(mycookie:str): return f'Got {mycookie}'
# If handlers return text instead of FTs, then a plaintext response is automatically created
print(client.get('/getcook').text)
Got foobar

FastHTML 通过使用特殊的 request 参数名(或该名称的任何前缀),自动提供对 Starlette 请求对象的访问。

@rt
def headers(req): return req.headers['host']

请求和会话对象

FastHTML 通过使用特殊的 session 参数名(或该名称的任何前缀),自动提供对 Starlette 会话中间件的访问。

@rt
def profile(req, sess, user_id: int=None):
    ip = req.client.host
    sess['last_visit'] = datetime.now().isoformat()
    visits = sess.setdefault('visit_count', 0) + 1
    sess['visit_count'] = visits
    user = get_user(user_id or sess.get('user_id'))
    return Titled(f"Profile: {user.name}", 
                  P(f"Visits: {visits}"), 
                  P(f"IP: {ip}"),
                  Button("Logout", hx_post=logout))

处理函数可以返回 HtmxResponseHeaders 对象来设置 HTMX 特定的响应头。

@rt
def htmlredirect(app): return HtmxResponseHeaders(location="http://example.org")

APIRouter

APIRouter 让你可以在 FastHTML 应用中跨多个文件组织路由。

# products.py
ar = APIRouter()

@ar
def details(pid: int): return f"Here are the product details for ID: {pid}"

@ar
def all_products(req):
    return Div(
        Div(
            Button("Details",hx_get=details.to(pid=42),hx_target="#products_list",hx_swap="outerHTML",),
        ), id="products_list")
# main.py
from products import ar,all_products

app, rt = fast_app()
ar.to_app(app)

@rt
def index():
    return Div(
        "Products",
        hx_get=all_products, hx_swap="outerHTML")

Toast 提示

Toast 提示可以有四种类型:

  • 信息 (info)
  • 成功 (success)
  • 警告 (warning)
  • 错误 (error)

Toast 提示需要使用 setup_toasts() 函数,并且每个处理程序都需要:

  • session 参数
  • 必须返回 FT 组件
setup_toasts(app)

@rt
def toasting(session):
    add_toast(session, f"cooked", "info")
    add_toast(session, f"ready", "success")
    return Titled("toaster")

setup_toasts(duration) 允许您指定 toast 在消失前可见的时长。默认为 10 秒。

身份验证和授权通过 Beforeware 处理,Beforeware 是在路由处理程序被调用之前运行的函数。

认证

def user_auth_before(req, sess):
    # `auth` key in the request scope is automatically provided to any handler which requests it and can not be injected
    auth = req.scope['auth'] = sess.get('auth', None)
    if not auth: return RedirectResponse('/login', status_code=303)

beforeware = Beforeware(
    user_auth_before,
    skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', r'.*\.js', '/login', '/']
)

app, rt = fast_app(before=beforeware)

服务器发送事件 (SSE)

FastHTML 支持 HTMX SSE 扩展。

import random
hdrs=(Script(src="https://unpkg.com/[email protected]/sse.js"),)
app,rt = fast_app(hdrs=hdrs)

@rt
def index(): return Div(hx_ext="sse", sse_connect="/numstream", hx_swap="beforeend show:bottom", sse_swap="message")

# `signal_shutdown()` gets an event that is set on shutdown
shutdown_event = signal_shutdown()

async def number_generator():
    while not shutdown_event.is_set():
        data = Article(random.randint(1, 100))
        yield sse_message(data)

@rt
async def numstream(): return EventStream(number_generator())

Websockets

FastHTML 为 HTMX 的 websockets 扩展提供了有用的工具。

# These HTMX extensions are available through `exts`:
#   head-support preload class-tools loading-states multi-swap path-deps remove-me ws chunked-transfer
app, rt = fast_app(exts='ws')

def mk_inp(): return Input(id='msg', autofocus=True)

@rt
async def index(request):
    # `ws_send` tells HTMX to send a message to the nearest websocket based on the trigger for the form element
    cts = Div(
        Div(id='notifications'),
        Form(mk_inp(), id='form', ws_send=True),
        hx_ext='ws', ws_connect='/ws')
    return Titled('Websocket Test', cts)

async def on_connect(send): await send(Div('Hello, you have connected', id="notifications"))
async def on_disconnect(ws): print('Disconnected!')

@app.ws('/ws', conn=on_connect, disconn=on_disconnect)
async def ws(msg:str, send):
    # websocket hander returns/sends are treated as OOB swaps
    await send(Div('Hello ' + msg, id="notifications"))
    return Div('Goodbye ' + msg, id="notifications"), mk_inp()

一个使用 FastHTML 的 setup_ws 函数的聊天机器人示例:

app = FastHTML(exts='ws')
rt = app.route
msgs = []

@rt('/')
def home():
    return Div(hx_ext='ws', ws_connect='/ws')(
        Div(Ul(*[Li(m) for m in msgs], id='msg-list')),
        Form(Input(id='msg'), id='form', ws_send=True)
    )

async def ws(msg:str):
    msgs.append(msg)
    await send(Ul(*[Li(m) for m in msgs], id='msg-list'))

send = setup_ws(app, ws)

单文件上传

Form 默认为 “multipart/form-data”。一个 Starlette UploadFile 对象会被传递给处理程序。

upload_dir = Path("filez")

@rt
def index():
    return (
        Form(hx_post=upload, hx_target="#result")(
            Input(type="file", name="file"),
            Button("Upload", type="submit")),
        Div(id="result")
    )

# Use `async` handlers where IO is used to avoid blocking other clients
@rt
async def upload(file: UploadFile):
    filebuffer = await file.read()
    (upload_dir / file.filename).write_bytes(filebuffer)
    return P('Size: ', file.size)

对于多文件上传,使用 Input(..., multiple=True),并在处理程序中使用 list[UploadFile] 的类型注解。

Fastlite

Fastlite 及其所基于的 MiniDataAPI 规范是一个面向 CRUD 的 API,用于操作 SQLite。它使用 APSW 和 apswutils 连接 SQLite,并为速度和清晰的错误处理进行了优化。

from fastlite import *
db = database(':memory:') # or database('data/app.db')

表通常用类来构造,字段类型通过类型提示指定。

class Book: isbn: str; title: str; pages: int; userid: int
# The transform arg instructs fastlite to change the db schema when fields change.
# Create only creates a table if the table doesn't exist.
books = db.create(Book, pk='isbn', transform=True)
                
class User: id: int; name: str; active: bool = True
# If no pk is provided, id is used as the primary key.
users = db.create(User, transform=True)
users
<Table user (id, name, active)>

Fastlite CRUD 操作

fastlite 中的每个操作都返回一个完整的数据类功能的超集。

user = users.insert(name='Alex',active=False)
user
User(id=1, name='Alex', active=0)
# List all records
users()
[User(id=1, name='Alex', active=0)]
# Limit, offset, and order results:
users(order_by='name', limit=2, offset=1)

# Filter on the results
users(where="name='Alex'")

# Placeholder for avoiding injection attacks
users("name=?", ('Alex',))

# A single record by pk
users[user.id]
User(id=1, name='Alex', active=0)

通过对主键使用 in 关键字来测试记录是否存在。

1 in users
True

更新操作(接受字典或类型化对象)返回更新后的记录。

user.name='Lauren'
user.active=True
users.update(user)
User(id=1, name='Lauren', active=1)

使用 .xtra() 来自动约束此后的查询、更新和插入操作。

users.xtra(active=True)
users()
[User(id=1, name='Lauren', active=1)]

按主键删除:

users.delete(user.id)
<Table user (id, name, active)>

通过主键 []、更新和删除操作会引发 NotFoundError。

try: users['Amy']
except NotFoundError: print('User not found')
User not found

MonsterUI

MonsterUI 是一个类似 shadcn 的 FastHTML 组件库。它为 FastHTML 添加了基于 Tailwind 的 FrankenUI 和 DaisyUI 库,以及用于 Markdown 的 Python mistletoe、用于代码高亮的 HighlightJS 和用于 LaTeX 支持的 Katex,并尽可能遵循语义化 HTML 模式。当您希望超越 FastHTML 内置 pico 支持的基础功能时,推荐使用它。

一个最小化应用:

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(hdrs=Theme.blue.headers(highlightjs=True)) # Use MonsterUI blue theme and highlight code in markdown

@rt
def index():
    socials = (('github','https://github.com/AnswerDotAI/MonsterUI'),)
    return Titled("App",
        Card(
            P("App", cls=TextPresets.muted_sm),
            # LabelInput, DivLAigned, and UkIconLink are non-semantic MonsterUI FT Components,
            LabelInput('Email', type='email', required=True),
            footer=DivLAligned(*[UkIconLink(icon,href=url) for icon,url in socials])))

MonsterUI 建议

  • 尽可能使用默认值,例如 monsterui 中的 Container 已经有默认的外边距。
  • 使用 *T 来保持按钮样式的一致性,例如 cls=ButtonT.destructive 用于红色的删除按钮,或 cls=ButtonT.primary 用于号召性用语(CTA)按钮。
  • 尽可能使用 Label* 函数来创建表单(例如 LabelInputLabelRange),它会创建并适当地链接 FormLabel 和用户输入,以避免样板代码。

Flex 布局元素(例如 DivLAlignedDivFullySpaced)可用于简洁地创建布局。

def TeamCard(name, role, location="Remote"):
    icons = ("mail", "linkedin", "github")
    return Card(
        DivLAligned(
            DiceBearAvatar(name, h=24, w=24),
            Div(H3(name), P(role))),
        footer=DivFullySpaced(
            DivHStacked(UkIcon("map-pin", height=16), P(location)),
            DivHStacked(*(UkIconLink(icon, height=16) for icon in icons))))

表单已为您设置好样式和间距,无需大量额外的类。

def MonsterForm():
    relationship = ["Parent",'Sibling', "Friend", "Spouse", "Significant Other", "Relative", "Child", "Other"]
    return Div(
        DivCentered(
            H3("Emergency Contact Form"),
            P("Please fill out the form completely", cls=TextPresets.muted_sm)),
        Form(
            Grid(LabelInput("Name",id='name'),LabelInput("Email",     id='email')),
            H3("Relationship to patient"),
            Grid(*[LabelCheckboxX(o) for o in relationship], cols=4, cls='space-y-3'),
            DivCentered(Button("Submit Form", cls=ButtonT.primary))),
        cls='space-y-4')

使用 MonsterUI,文本可以自动用 markdown 设置样式。

render_md("""
# My Document

> Important note here

+ List item with **bold**
+ Another with `code`

```python
def hello():
    print("world")
```
""")
'<div><h1 class="uk-h1 text-4xl font-bold mt-12 mb-6">My Document</h1>\n<blockquote class="uk-blockquote pl-4 border-l-4 border-primary italic mb-6">\n<p class="text-lg leading-relaxed mb-6">Important note here</p>\n</blockquote>\n<ul class="uk-list uk-list-bullet space-y-2 mb-6 ml-6 text-lg">\n<li class="leading-relaxed">List item with <strong>bold</strong></li>\n<li class="leading-relaxed">Another with <code class="uk-codespan px-1">code</code></li>\n</ul>\n<pre class="bg-base-200 rounded-lg p-4 mb-6"><code class="language-python uk-codespan px-1 uk-codespan px-1 block overflow-x-auto">def hello():\n    print("world")\n</code></pre>\n</div>'

或者使用语义化 HTML:

def SemanticText():
    return Card(
        H1("MonsterUI's Semantic Text"),
        P(
            Strong("MonsterUI"), " brings the power of semantic HTML to life with ",
            Em("beautiful styling"), " and ", Mark("zero configuration"), "."),
        Blockquote(
            P("Write semantic HTML in pure Python, get modern styling for free."),
            Cite("MonsterUI Team")),
        footer=Small("Released February 2025"),)