├── nbs ├── .gitignore ├── api │ ├── README.txt │ └── 09_cli.ipynb ├── favicon.ico ├── og-image.png ├── robots.txt ├── explains │ ├── refund.png │ ├── CreateWebhook.png │ ├── CreateWebhook2.png │ ├── imgs │ │ └── gh-oauth.png │ ├── StripePaymentPage.jpg │ ├── SubscriptionEvents.png │ ├── StripeDashboard_API_Key.png │ ├── faq.ipynb │ ├── websockets.ipynb │ ├── stripe_otp.py │ ├── background_tasks.ipynb │ └── routes.ipynb ├── fonts │ ├── Geist-Bold.woff │ ├── Geist-Bold.woff2 │ ├── Geist-Regular.woff │ ├── Geist-Regular.woff2 │ ├── GeistMono-Bold.woff │ ├── GeistMono-Bold.woff2 │ ├── GeistMono-Regular.woff │ └── GeistMono-Regular.woff2 ├── tutorials │ ├── imgs │ │ └── quickdraw.png │ ├── quickstart-web-dev │ │ ├── quickstart-charts.png │ │ ├── quickstart-fasthtml.png │ │ └── quickstart-sakura.png │ ├── hello_world.py │ ├── index.qmd │ ├── 01_quickdraw.py │ ├── 02_quickdraw.py │ ├── 03_quickdraw.py │ └── jupyter_and_fasthtml.ipynb ├── unpublished │ └── web-dev-tut │ │ └── random-list-letters.png ├── nbdev.yml ├── custom.scss ├── fix-commonmark.html ├── styles.css ├── _quarto.yml ├── ref │ ├── live_reload.ipynb │ ├── best_practice.qmd │ └── response_types.ipynb ├── llms.txt └── index.ipynb ├── demo ├── requirements.txt ├── favicon.ico └── main.py ├── examples ├── example.js ├── favicon.ico ├── picovars.css ├── minimal.py ├── README.md ├── chat_ws.py ├── basic_ws.py ├── todos3.py ├── todos1.py ├── todos4.py ├── todos2.py ├── user_app.py ├── adv_app_strip.py └── pep8_app.py ├── fasthtml ├── __init__.py ├── basics.py ├── ft.py ├── common.py ├── katex.js ├── starlette.py ├── cli.py ├── authmw.py ├── live_reload.py ├── toaster.py ├── pico.py ├── fastapp.py ├── js.py ├── jupyter.py └── svg.py ├── mds.zip ├── MANIFEST.in ├── .github ├── workflows │ ├── test.yaml │ └── deploy.yaml.off ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── bug_report.md │ └── documentation.md └── pull_request_template.md ├── tools ├── update.sh └── mk_pyi.py ├── docs └── index.html ├── .codespellrc ├── pyproject.toml ├── settings.ini ├── tests └── test_toaster.py ├── .gitignore ├── setup.py ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── README.md /nbs/.gitignore: -------------------------------------------------------------------------------- 1 | /.quarto/ 2 | -------------------------------------------------------------------------------- /demo/requirements.txt: -------------------------------------------------------------------------------- 1 | python-fasthtml -------------------------------------------------------------------------------- /examples/example.js: -------------------------------------------------------------------------------- 1 | console.log(1) 2 | -------------------------------------------------------------------------------- /fasthtml/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.12.37" 2 | from .core import * 3 | -------------------------------------------------------------------------------- /mds.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/mds.zip -------------------------------------------------------------------------------- /nbs/api/README.txt: -------------------------------------------------------------------------------- 1 | These are the source notebooks for FastHTML. 2 | 3 | -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/demo/favicon.ico -------------------------------------------------------------------------------- /nbs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/favicon.ico -------------------------------------------------------------------------------- /nbs/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/og-image.png -------------------------------------------------------------------------------- /examples/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/examples/favicon.ico -------------------------------------------------------------------------------- /nbs/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Sitemap: https://www.fastht.ml/docs/sitemap.xml 4 | -------------------------------------------------------------------------------- /fasthtml/basics.py: -------------------------------------------------------------------------------- 1 | from .core import * 2 | from .components import * 3 | from .xtend import * 4 | 5 | -------------------------------------------------------------------------------- /nbs/explains/refund.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/refund.png -------------------------------------------------------------------------------- /fasthtml/ft.py: -------------------------------------------------------------------------------- 1 | from fastcore.xml import * 2 | from .components import * 3 | from .xtend import * 4 | 5 | -------------------------------------------------------------------------------- /nbs/fonts/Geist-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/Geist-Bold.woff -------------------------------------------------------------------------------- /nbs/fonts/Geist-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/Geist-Bold.woff2 -------------------------------------------------------------------------------- /nbs/fonts/Geist-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/Geist-Regular.woff -------------------------------------------------------------------------------- /examples/picovars.css: -------------------------------------------------------------------------------- 1 | /* Used in demonstrating how to link to a css file */ 2 | :root { --pico-font-size: 100%; } 3 | -------------------------------------------------------------------------------- /nbs/explains/CreateWebhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/CreateWebhook.png -------------------------------------------------------------------------------- /nbs/explains/CreateWebhook2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/CreateWebhook2.png -------------------------------------------------------------------------------- /nbs/explains/imgs/gh-oauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/imgs/gh-oauth.png -------------------------------------------------------------------------------- /nbs/fonts/Geist-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/Geist-Regular.woff2 -------------------------------------------------------------------------------- /nbs/fonts/GeistMono-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/GeistMono-Bold.woff -------------------------------------------------------------------------------- /nbs/fonts/GeistMono-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/GeistMono-Bold.woff2 -------------------------------------------------------------------------------- /nbs/fonts/GeistMono-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/GeistMono-Regular.woff -------------------------------------------------------------------------------- /nbs/fonts/GeistMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/fonts/GeistMono-Regular.woff2 -------------------------------------------------------------------------------- /nbs/tutorials/imgs/quickdraw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/tutorials/imgs/quickdraw.png -------------------------------------------------------------------------------- /nbs/explains/StripePaymentPage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/StripePaymentPage.jpg -------------------------------------------------------------------------------- /nbs/explains/SubscriptionEvents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/SubscriptionEvents.png -------------------------------------------------------------------------------- /nbs/explains/StripeDashboard_API_Key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/explains/StripeDashboard_API_Key.png -------------------------------------------------------------------------------- /nbs/unpublished/web-dev-tut/random-list-letters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/unpublished/web-dev-tut/random-list-letters.png -------------------------------------------------------------------------------- /nbs/tutorials/quickstart-web-dev/quickstart-charts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/tutorials/quickstart-web-dev/quickstart-charts.png -------------------------------------------------------------------------------- /nbs/tutorials/quickstart-web-dev/quickstart-fasthtml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/tutorials/quickstart-web-dev/quickstart-fasthtml.png -------------------------------------------------------------------------------- /nbs/tutorials/quickstart-web-dev/quickstart-sakura.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnswerDotAI/fasthtml/HEAD/nbs/tutorials/quickstart-web-dev/quickstart-sakura.png -------------------------------------------------------------------------------- /nbs/tutorials/hello_world.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | 3 | app = FastHTML() 4 | rt = app.route 5 | 6 | @rt('/') 7 | def get(): 8 | return 'Hello, world!' 9 | 10 | serve() -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include fasthtml/*.pyi 2 | include fasthtml/*.js 3 | include settings.ini 4 | include LICENSE 5 | include CONTRIBUTING.md 6 | include README.md 7 | recursive-exclude * __pycache__ 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [workflow_dispatch, pull_request, push] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: [uses: fastai/workflows/nbdev-ci@master] 8 | -------------------------------------------------------------------------------- /tools/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | python tools/mk_pyi.py 3 | llms_txt2ctx nbs/llms.txt --optional true > nbs/llms-ctx-full.txt 4 | llms_txt2ctx nbs/llms.txt > nbs/llms-ctx.txt 5 | pysym2md --output_file nbs/apilist.txt fasthtml --include_no_docstring 6 | 7 | -------------------------------------------------------------------------------- /nbs/nbdev.yml: -------------------------------------------------------------------------------- 1 | project: 2 | output-dir: _docs 3 | 4 | website: 5 | title: "fasthtml" 6 | site-url: "https://www.fastht.ml/docs/" 7 | description: "The fastest way to create an HTML app" 8 | repo-branch: main 9 | repo-url: "https://github.com/AnswerDotAI/fasthtml" 10 | -------------------------------------------------------------------------------- /nbs/tutorials/index.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | order: 1 3 | title: Tutorials 4 | listing: 5 | fields: [title, description] 6 | type: table 7 | sort-ui: false 8 | filter-ui: false 9 | --- 10 | 11 | Click through to any of these tutorials to get started with FastHTML's features. 12 | 13 | -------------------------------------------------------------------------------- /examples/minimal.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | from fasthtml.common import * 3 | app,rt = fast_app( hdrs=[Script(src='example.js')]) 4 | 5 | @rt 6 | def rnd(): return P(random()) 7 | 8 | @rt 9 | def index(): return Titled( 'Hello', Div(P('click'), hx_post=rnd)) 10 | 11 | serve() 12 | 13 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Redirecting... 6 | 7 | 8 |

If you are not redirected automatically, click here.

9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Start a discussion 4 | url: https://github.com/AnswerDotAI/fasthtml/discussions 5 | about: You can ask for help here! 6 | - name: Join our Discord 7 | url: https://discord.gg/qcXvcxMhdP 8 | about: Join our community on Discord! 9 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml.off: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | permissions: 3 | contents: write 4 | pages: write 5 | 6 | on: 7 | push: 8 | branches: [ "main", "master" ] 9 | workflow_dispatch: 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: [uses: fastai/workflows/quarto-ghp@master] 14 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains some examples of the library in action, including a sequence of to-do list apps of increasing complexity (see `todos1.py` through `todos5.py`, along with `adv_app.py`). For more examples of different applications and components, see the separate [examples repository](https://github.com/AnswerDotAI/fasthtml-example). 4 | 5 | -------------------------------------------------------------------------------- /fasthtml/common.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from .starlette import * 6 | from fastcore.utils import * 7 | from fastcore.xml import * 8 | from apswutils import Database 9 | from fastlite import * 10 | from .basics import * 11 | from .pico import * 12 | from .authmw import * 13 | from .live_reload import * 14 | from .toaster import * 15 | from .js import * 16 | from .fastapp import * 17 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | # See: https://github.com/codespell-project/codespell#using-a-config-file 2 | [codespell] 3 | # In the event of a false positive, add the problematic word, in all lowercase, to a comma-separated list here: 4 | ignore-words-list = convertor,convertors,noo,socio-economic 5 | # If a file has many false positives, exclude here: 6 | skip = ./.git 7 | # To exclude base64 encoded strings, ignore all long base64 compatible strings 8 | ignore-regex = [A-Za-z0-9+/]{1000,} 9 | -------------------------------------------------------------------------------- /nbs/custom.scss: -------------------------------------------------------------------------------- 1 | /*-- scss:defaults --*/ 2 | 3 | $body-bg: #FFFFFF !default; 4 | $body-color: #000000 !default; 5 | $link-color: #4040BF !default; 6 | $primary: #E8E8FC !default; 7 | 8 | $code-bg: null !default; // Placeholder for default value 9 | $code-color: null !default; // Placeholder for default value 10 | 11 | $navbar-bg: #3CDD8C !default; 12 | $navbar-fg: #FFFFFF !default; 13 | $navbar-hl: #000000 !default; 14 | 15 | $font-size-root: 18px !default; 16 | 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name="python-fasthtml" 7 | requires-python=">=3.10" 8 | dynamic = [ "keywords", "description", "version", "dependencies", "optional-dependencies", "readme", "license", "authors", "classifiers", "entry-points", "scripts", "urls"] 9 | 10 | [tool.uv] 11 | cache-keys = [{ file = "pyproject.toml" }, { file = "settings.ini" }, { file = "setup.py" }] 12 | -------------------------------------------------------------------------------- /examples/chat_ws.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | 3 | app = FastHTML(exts='ws') 4 | rt = app.route 5 | 6 | msgs = [] 7 | @rt('/') 8 | def home(): 9 | return Div(hx_ext='ws', ws_connect='/ws')( 10 | Div(Ul(*[Li(m) for m in msgs], id='msg-list')), 11 | Form(Input(id='msg'), id='form', ws_send=True) 12 | ) 13 | 14 | async def ws(msg:str): 15 | msgs.append(msg) 16 | await send(Ul(*[Li(m) for m in msgs], id='msg-list')) 17 | 18 | send = app.setup_ws(ws) 19 | 20 | serve() 21 | -------------------------------------------------------------------------------- /nbs/fix-commonmark.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/basic_ws.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | from fasthtml.common import * 3 | 4 | app = FastHTML(exts='ws') 5 | rt = app.route 6 | 7 | def mk_inp(): return Input(id='msg') 8 | nid = 'notifications' 9 | 10 | @rt('/') 11 | async def get(): 12 | cts = Div( 13 | Div(id=nid), 14 | Form(mk_inp(), id='form', ws_send=True), 15 | hx_ext='ws', ws_connect='/ws') 16 | return Titled('Websocket Test', cts) 17 | 18 | async def on_connect(send): await send(Div('Hello, you have connected', id=nid)) 19 | async def on_disconnect( ): print('Disconnected!') 20 | 21 | @app.ws('/ws', conn=on_connect, disconn=on_disconnect) 22 | async def ws(msg:str, send): 23 | await send(Div('Hello ' + msg, id=nid)) 24 | await sleep(2) 25 | return Div('Goodbye ' + msg, id=nid), mk_inp() 26 | 27 | serve() 28 | 29 | -------------------------------------------------------------------------------- /tools/mk_pyi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from fasthtml.common import * 3 | from fasthtml.components import _all_, hx_attrs_annotations 4 | from fastcore.py2pyi import create_pyi 5 | 6 | create_pyi('fasthtml/core.py', 'fasthtml') 7 | create_pyi('fasthtml/components.py', 'fasthtml') 8 | create_pyi('fasthtml/xtend.py', 'fasthtml') 9 | with open('fasthtml/components.pyi', 'a') as f: 10 | attrs_str = ', '.join(f'{t}:Any=None' for t in hx_attrs) 11 | f.write(f"\ndef ft_html(tag: str, *c, {attrs_str}, **kwargs): ...\n") 12 | f.write(f"def ft_hx(tag: str, *c, {attrs_str}, **kwargs): ...\n") 13 | for o in _all_: 14 | attrs = (['name'] if o.lower() in named else []) + hx_attrs + evt_attrs 15 | attrs_str = ', '.join(f'{t}:{"Any" if t not in hx_attrs_annotations else str(hx_attrs_annotations[t]).replace("typing.","")}=None' for t in attrs) 16 | f.write(f"def {o}(*c, {attrs_str}, **kwargs): ...\n") 17 | -------------------------------------------------------------------------------- /demo/main.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | 3 | def render(item:'Todo'): 4 | id = f'todo-{item.id}' 5 | dellink = AX('Delete', hx_delete=f'/todo/{item.id}', target_id=id, hx_swap='delete') 6 | return Li(item.task, dellink, id=id) 7 | 8 | auth = user_pwd_auth(user='s3kret', skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css']) 9 | app,rt,todos,Todo = fast_app( 10 | 'data/tbl.db', middleware=[auth], render=render, 11 | id=int, task=str, pk='id') 12 | 13 | @rt("/") 14 | async def get(request): 15 | new_frm = Form(hx_post='/', target_id='todo-list', hx_swap='beforeend')( 16 | Group( 17 | Input(name='task', placeholder='Task'), 18 | Button('Add') 19 | ) 20 | ) 21 | items = Ul(*todos(), id='todo-list') 22 | logout = A('logout', href=basic_logout(request)) 23 | return Titled('Todo list', new_frm, items, logout) 24 | 25 | @rt("/") 26 | async def post(todo:Todo): return todos.insert(todo) 27 | 28 | @rt("/todo/{id}") 29 | async def delete(id:int): todos.delete(id) 30 | 31 | serve() 32 | -------------------------------------------------------------------------------- /nbs/tutorials/01_quickdraw.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | from datetime import datetime 3 | 4 | def render(room): 5 | return Li(A(room.name, href=f"/rooms/{room.id}")) 6 | 7 | app,rt,rooms,Room = fast_app('data/drawapp.db', render=render, id=int, name=str, created_at=str, pk='id') 8 | 9 | @rt("/") 10 | def get(): 11 | # The 'Input' id defaults to the same as the name, so you can omit it if you wish 12 | create_room = Form(Input(id="name", name="name", placeholder="New Room Name"), 13 | Button("Create Room"), 14 | hx_post="/rooms", hx_target="#rooms-list", hx_swap="afterbegin") 15 | rooms_list = Ul(*rooms(order_by='id DESC'), id='rooms-list') 16 | return Titled("QuickDraw", create_room, rooms_list) 17 | 18 | @rt("/rooms") 19 | async def post(room:Room): 20 | room.created_at = datetime.now().isoformat() 21 | return rooms.insert(room) 22 | 23 | @rt("/rooms/{id}") 24 | async def get(id:int): 25 | room = rooms[id] 26 | return Titled(f"Room: {room.name}", H1(f"Welcome to {room.name}"), A(Button("Leave Room"), href="/")) 27 | 28 | serve() -------------------------------------------------------------------------------- /nbs/styles.css: -------------------------------------------------------------------------------- 1 | .cell { margin-bottom: 1rem; } 2 | .cell > .sourceCode { margin-bottom: 0; } 3 | .cell-output > pre { margin-bottom: 0; } 4 | 5 | .cell-output > pre, .cell-output > .sourceCode > pre, .cell-output-stdout > pre { 6 | margin-left: 0.8rem; 7 | margin-top: 0; 8 | background: none; 9 | border-left: 2px solid lightsalmon; 10 | border-top-left-radius: 0; 11 | border-top-right-radius: 0; 12 | } 13 | 14 | .cell-output > .sourceCode { border: none; } 15 | 16 | .cell-output > .sourceCode { 17 | background: none; 18 | margin-top: 0; 19 | } 20 | 21 | div.description { 22 | padding-left: 2px; 23 | padding-top: 5px; 24 | font-size: 1.25rem; 25 | color: rgba(0, 0, 0, 0.60); 26 | opacity: 70%; 27 | } 28 | 29 | 30 | div.sidebar-item-container .active { 31 | background-color: #E8E8FC; 32 | color: #000; 33 | } 34 | 35 | div.sidebar-item-container a { 36 | color: #000; 37 | padding: 4px 6px; 38 | border-radius: 6px; 39 | font-size: 1rem; 40 | } 41 | 42 | li.sidebar-item { 43 | margin-top: 3px; 44 | } 45 | 46 | span.menu-text { 47 | line-height: 20px; 48 | } 49 | 50 | .navbar-container { max-width: 1282px; } 51 | 52 | -------------------------------------------------------------------------------- /fasthtml/katex.js: -------------------------------------------------------------------------------- 1 | import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js"; 2 | import katex from "https://cdn.jsdelivr.net/npm/katex/dist/katex.mjs"; 3 | 4 | const renderMath = (tex, displayMode) => { return katex.renderToString(tex, { 5 | throwOnError: false, displayMode: displayMode, output: 'html', trust: true 6 | }) }; 7 | 8 | const processLatexEnvironments = (content) => { 9 | return content.replace(/\\begin{(\w+)}([\s\S]*?)\\end{\1}/g, (match, env, innerContent) => { 10 | if ([{env_list}].includes(env)) { return `{display_delim}${match}{display_delim}`; } 11 | return match; 12 | }) }; 13 | 14 | proc_htmx('{sel}', e => { 15 | let content = processLatexEnvironments(e.textContent); 16 | // Display math (including environments) 17 | content = content.replace(/{display_delim}([\s\S]+?){display_delim}/gm, (_, tex) => renderMath(tex.trim(), true)); 18 | // Inline math 19 | content = content.replace(/(? renderMath(tex.trim(), false)); 20 | e.innerHTML = marked.parse(content); 21 | }); 22 | 23 | -------------------------------------------------------------------------------- /settings.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | repo = fasthtml 3 | lib_name = python-fasthtml 4 | version = 0.12.37 5 | min_python = 3.10 6 | license = apache2 7 | requirements = fastcore>=1.8.1 python-dateutil starlette>0.33 oauthlib itsdangerous uvicorn[standard]>=0.30 httpx fastlite>=0.1.1 python-multipart beautifulsoup4 8 | dev_requirements = ipython lxml pysymbol_llm monsterui PyJWT 9 | black_formatting = False 10 | conda_user = fastai 11 | doc_path = _docs 12 | lib_path = fasthtml 13 | nbs_path = nbs 14 | recursive = True 15 | tst_flags = notest 16 | put_version_in_init = True 17 | branch = main 18 | custom_sidebar = False 19 | doc_host = https://www.fastht.ml 20 | doc_baseurl = /docs/ 21 | git_url = https://github.com/AnswerDotAI/fasthtml 22 | title = fasthtml 23 | audience = Developers 24 | author = Jeremy Howard and contributors 25 | author_email = github@jhoward.fastmail.fm 26 | copyright = 2024 onwards, Jeremy Howard 27 | description = The fastest way to create an HTML app 28 | keywords = nbdev jupyter notebook python 29 | language = English 30 | status = 3 31 | console_scripts = fh_railway_link=fasthtml.cli:railway_link 32 | fh_railway_deploy=fasthtml.cli:railway_deploy 33 | user = AnswerDotAI 34 | readme_nb = index.ipynb 35 | allowed_metadata_keys = 36 | allowed_cell_metadata_keys = 37 | jupyter_hooks = True 38 | clean_ids = True 39 | clear_all = False 40 | cell_number = False 41 | skip_procs = 42 | update_pyproject = True 43 | 44 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request 3 | about: Propose changes to the codebase 4 | title: '[PR] ' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Related Issue** 11 | Please link to the issue that this pull request addresses. If there isn't one, please create an issue first. 12 | 13 | **Proposed Changes** 14 | Describe the big picture of your changes here. If it fixes a bug or resolves a feature request, be sure to link to that issue. 15 | 16 | **Types of changes** 17 | What types of changes does your code introduce? Put an `x` in all the boxes that apply: 18 | - [ ] Bug fix (non-breaking change which fixes an issue) 19 | - [ ] New feature (non-breaking change which adds functionality) 20 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 21 | 22 | **Checklist** 23 | Go over all the following points, and put an `x` in all the boxes that apply: 24 | - [ ] My code follows the code style of this project. 25 | - [ ] My change requires a change to the documentation. 26 | - [ ] I have updated the documentation accordingly. 27 | - [ ] I have added tests to cover my changes. 28 | - [ ] All new and existing tests passed. 29 | - [ ] I am aware that this is an nbdev project, and I have edited, cleaned, and synced the source notebooks instead of editing .py or .md files directly. 30 | 31 | **Additional Information** 32 | Any additional information, configuration or data that might be necessary to reproduce the issue. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | If you'd like to discuss your feature idea first with the community (highly recommended!) please visit our [Discord channel](https://discord.gg/qcXvcxMhdP). 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Example code** 19 | Provide an example of how you imagine the feature working: 20 | 21 | ```python 22 | # Your example code here 23 | ``` 24 | 25 | **Similar implementations** 26 | If available, provide links to similar features in other libraries: 27 | 1. [Library Name](link-to-feature) 28 | 2. [Another Library](another-link-to-feature) 29 | 30 | **Problem solved** 31 | Explain what problem this feature would solve and how it would benefit users of the library: 32 | 33 | **Additional context** 34 | Add any other context or screenshots about the feature request here. 35 | 36 | **Confirmation** 37 | Please confirm the following: 38 | - [ ] I have checked the existing issues and pull requests to ensure this feature hasn't been requested before. 39 | - [ ] I have read the project's documentation to ensure this feature doesn't already exist. 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Important Notice** 11 | We do not provide support through GitHub issues. For community-based help, please use either: 12 | - GitHub Discussions tab 13 | - Our [Discord channel](https://discord.gg/qcXvcxMhdP) 14 | 15 | If you're reporting a bug, please continue with this template. 16 | 17 | **Describe the bug** 18 | A clear and concise description of what the bug is. 19 | 20 | **Minimal Reproducible Example** 21 | Provide a minimal code snippet that reproduces the issue. This is crucial for us to understand and fix the bug quickly. 22 | 23 | ```python 24 | # Your code here 25 | ``` 26 | 27 | **Expected behavior** 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Environment Information** 31 | Please provide the following version information: 32 | - fastlite version: 33 | - fastcore version: 34 | - fasthtml version: 35 | 36 | **Confirmation** 37 | Please confirm the following: 38 | - [ ] I have read the FAQ (https://docs.fastht.ml/explains/faq.html) 39 | - [ ] I have provided a minimal reproducible example 40 | - [ ] I have included the versions of fastlite, fastcore, and fasthtml 41 | - [ ] I understand that this is a volunteer open source project with no commercial support. 42 | 43 | **Additional context** 44 | Add any other context about the problem here. 45 | 46 | **Screenshots** 47 | If applicable, add screenshots to help explain your problem. 48 | -------------------------------------------------------------------------------- /nbs/_quarto.yml: -------------------------------------------------------------------------------- 1 | project: 2 | type: website 3 | pre-render: 4 | - pysym2md --output_file apilist.txt fasthtml 5 | resources: 6 | - "*.txt" 7 | preview: 8 | navigate: false 9 | port: 3000 10 | 11 | format: 12 | html: 13 | theme: 14 | - cosmo 15 | - custom.scss 16 | css: styles.css 17 | include-after-body: fix-commonmark.html 18 | toc: true 19 | keep-md: true 20 | canonical-url: true 21 | commonmark: default 22 | 23 | website: 24 | favicon: favicon.ico 25 | twitter-card: 26 | creator: "@jeremyphoward" 27 | site: "@answerdotai" 28 | image: https://www.fastht.ml/docs/og-image.png 29 | open-graph: true 30 | repo-actions: [issue] 31 | navbar: 32 | title: false 33 | logo: "logo.svg" 34 | background: primary 35 | search: true 36 | left: 37 | - text: "Home" 38 | href: https://fastht.ml 39 | - text: "Learn" 40 | href: https://fastht.ml/about 41 | right: 42 | - icon: github 43 | href: "https://github.com/answerdotai/fasthtml" 44 | - icon: twitter 45 | href: https://x.com/answerdotai 46 | aria-label: Fast.ai Twitter 47 | sidebar: 48 | style: floating 49 | contents: 50 | - text: "Get Started" 51 | href: index.ipynb 52 | - section: Tutorials 53 | contents: tutorials/* 54 | - section: Explanations 55 | contents: explains/* 56 | - section: Reference 57 | contents: ref/* 58 | - section: Source 59 | contents: api/* 60 | 61 | metadata-files: [nbdev.yml] 62 | -------------------------------------------------------------------------------- /fasthtml/starlette.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | from starlette.middleware import Middleware 3 | from starlette.middleware.sessions import SessionMiddleware 4 | from starlette.middleware.cors import CORSMiddleware 5 | from starlette.middleware.authentication import AuthenticationMiddleware 6 | from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser, requires 7 | from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware 8 | from starlette.middleware.trustedhost import TrustedHostMiddleware 9 | from starlette.responses import Response, HTMLResponse, FileResponse, JSONResponse as JSONResponseOrig, RedirectResponse, StreamingResponse 10 | from starlette.requests import Request, HTTPConnection, FormData 11 | from starlette.staticfiles import StaticFiles 12 | from starlette.exceptions import HTTPException 13 | from starlette._utils import is_async_callable 14 | from starlette.convertors import Convertor, StringConvertor, register_url_convertor, CONVERTOR_TYPES 15 | from starlette.routing import Route, Router, Mount, WebSocketRoute 16 | from starlette.exceptions import HTTPException,WebSocketException 17 | from starlette.endpoints import HTTPEndpoint,WebSocketEndpoint 18 | from starlette.config import Config 19 | from starlette.datastructures import CommaSeparatedStrings, Secret, UploadFile, URLPath 20 | from starlette.types import ASGIApp, Receive, Scope, Send 21 | from starlette.concurrency import run_in_threadpool 22 | from starlette.background import BackgroundTask, BackgroundTasks 23 | from starlette.websockets import WebSocketDisconnect, WebSocket 24 | 25 | -------------------------------------------------------------------------------- /examples/todos3.py: -------------------------------------------------------------------------------- 1 | # Run with: python basic_app.py 2 | from fasthtml.common import * 3 | 4 | def render(todo): 5 | show = AX(todo.task, f'/todos/{todo.id}', 'current-todo') 6 | edit = AX('edit', f'/edit/{todo.id}' , 'current-todo') 7 | dt = ' (done)' if todo.done else '' 8 | return Li(show, dt, ' | ', edit, id=f'todo-{todo.id}') 9 | 10 | app,rt,todos,Todo = fast_app('data/todos.db', render, id=int, task=str, done=bool, pk='id') 11 | 12 | @rt("/") 13 | def get(): 14 | inp = Input(id="new-task", name="task", placeholder="New Todo") 15 | add = Form(Group(inp, Button("Add")), hx_post="/", target_id='todo-list', hx_swap="beforeend") 16 | card = Card(Ul(*todos(), id='todo-list'), header=add, footer=Div(id='current-todo')), 17 | return Titled('Todo list', card) 18 | 19 | @rt("/") 20 | def post(todo:Todo): 21 | return todos.insert(todo), Input(id="new-task", name="task", placeholder="New Todo", hx_swap_oob='true') 22 | 23 | @rt("/edit/{id}") 24 | def get(id:int): 25 | res = Form(Group(Input(id="task"), Button("Save")), 26 | Hidden(id="id"), CheckboxX(id="done", label='Done'), 27 | hx_put="/", target_id=f'todo-{id}', id="edit") 28 | return fill_form(res, todos[id]) 29 | 30 | @rt("/") 31 | def put(todo: Todo): return todos.update(todo), clear('current-todo') 32 | 33 | @rt("/todos/{id}") 34 | def get(id:int): 35 | todo = todos[id] 36 | btn = Button('delete', hx_delete=f'/todos/{todo.id}', target_id=f'todo-{id}', hx_swap="outerHTML") 37 | return Div(Div(todo.task), btn) 38 | 39 | @rt("/todos/{id}") 40 | def delete(id:int): 41 | todos.delete(id) 42 | return clear('current-todo') 43 | 44 | serve() 45 | 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Report missing, incomplete, or incorrect documentation 4 | title: "[DOCS]" 5 | labels: documentation 6 | assignees: '' 7 | --- 8 | 9 | **Issue Type** 10 | - [ ] Existing documentation (e.g. typos, errors, outdated information) 11 | - [ ] Suggest new documentation that is currently missing 12 | 13 | **Current Behavior or Documentation Gaps** 14 | For existing documentation: 15 | A clear and concise description of what is incorrect, missing, or confusing. Include a link to the specific documentation page, if applicable. 16 | 17 | For new documentation suggestions: 18 | Describe the functionality or topic that is currently undocumented, and explain briefly why it would be helpful to document it. 19 | 20 | **Suggested Improvement or New Documentation Content** 21 | Provide details on how to improve the documentation or what content should be added. If you're suggesting a new topic, explain what sections, examples, or tutorials would be beneficial. 22 | 23 | **Steps to Reproduce the Problem (if applicable)** 24 | If this is related to a documentation bug (such as incorrect code or instructions), please describe the steps to reproduce and fix the issue. 25 | 26 | **Relevant Links** 27 | If this issue relates to existing features, APIs, or other projects, please provide relevant links to help clarify the issue or suggested documentation. 28 | 29 | **Additional Context** 30 | Provide any other context or screenshots that might help explain the documentation issue. 31 | 32 | **Confirmation** 33 | Please confirm the following: 34 | - [ ] I have checked the existing issues and pull requests to ensure this documentation issue hasn't been reported before. 35 | - [ ] I have read the project's documentation and am confident this issue or suggestion is valid. 36 | -------------------------------------------------------------------------------- /examples/todos1.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | 3 | app,rt,todos,Todo = fast_app('data/todos.db', id=int, task=str, done=bool, pk='id') 4 | 5 | def TodoRow(todo): 6 | return Li( 7 | A(todo.task, href=f'/todos/{todo.id}'), 8 | (' (done)' if todo.done else '') + ' | ', 9 | A('edit', href=f'/edit/{todo.id}'), 10 | id=f'todo-{todo.id}' 11 | ) 12 | 13 | def home(): 14 | add = Form( 15 | Group( 16 | Input(name="task", placeholder="New Todo"), 17 | Button("Add") 18 | ), action="/", method='post' 19 | ) 20 | card = Card( 21 | Ul(*map(TodoRow, todos()), id='todo-list'), 22 | header=add, 23 | footer=Div(id='current-todo') 24 | ) 25 | return Titled('Todo list', card) 26 | 27 | @rt("/") 28 | def get(): return home() 29 | 30 | @rt("/") 31 | def post(todo:Todo): 32 | todos.insert(todo) 33 | return home() 34 | 35 | @rt("/update") 36 | def post(todo: Todo): 37 | todos.update(todo) 38 | return home() 39 | 40 | @rt("/remove") 41 | def get(id:int): 42 | todos.delete(id) 43 | return home() 44 | 45 | @rt("/edit/{id}") 46 | def get(id:int): 47 | res = Form( 48 | Group( 49 | Input(id="task"), 50 | Button("Save") 51 | ), 52 | Hidden(id="id"), 53 | CheckboxX(id="done", label='Done'), 54 | A('Back', href='/', role="button"), 55 | action="/update", id="edit", method='post' 56 | ) 57 | frm = fill_form(res, todos[id]) 58 | return Titled('Edit Todo', frm) 59 | 60 | @rt("/todos/{id}") 61 | def get(id:int): 62 | contents = Div( 63 | Div(todos[id].task), 64 | A('Delete', href=f'/remove?id={id}', role="button"), 65 | A('Back', href='/', role="button") 66 | ) 67 | return Titled('Todo details', contents) 68 | 69 | serve() 70 | 71 | -------------------------------------------------------------------------------- /nbs/tutorials/02_quickdraw.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | from datetime import datetime 3 | 4 | def render(room): 5 | return Li(A(room.name, href=f"/rooms/{room.id}")) 6 | 7 | app,rt,rooms,Room = fast_app('data/drawapp.db', render=render, id=int, name=str, created_at=str, pk='id') 8 | 9 | @rt("/") 10 | def get(): 11 | create_room = Form(Input(id="name", name="name", placeholder="New Room Name"), 12 | Button("Create Room"), 13 | hx_post="/rooms", hx_target="#rooms-list", hx_swap="afterbegin") 14 | rooms_list = Ul(*rooms(order_by='id DESC'), id='rooms-list') 15 | return Titled("QuickDraw", 16 | create_room, rooms_list) 17 | 18 | @rt("/rooms") 19 | async def post(room:Room): 20 | room.created_at = datetime.now().isoformat() 21 | return rooms.insert(room) 22 | 23 | @rt("/rooms/{id}") 24 | async def get(id:int): 25 | room = rooms[id] 26 | canvas = Canvas(id="canvas", width="800", height="600") 27 | color_picker = Input(type="color", id="color-picker", value="#3CDD8C") 28 | brush_size = Input(type="range", id="brush-size", min="1", max="50", value="10") 29 | 30 | js = """ 31 | var canvas = new fabric.Canvas('canvas'); 32 | canvas.isDrawingMode = true; 33 | canvas.freeDrawingBrush.color = '#3CDD8C'; 34 | canvas.freeDrawingBrush.width = 10; 35 | 36 | document.getElementById('color-picker').onchange = function() { 37 | canvas.freeDrawingBrush.color = this.value; 38 | }; 39 | 40 | document.getElementById('brush-size').oninput = function() { 41 | canvas.freeDrawingBrush.width = parseInt(this.value, 10); 42 | }; 43 | """ 44 | 45 | return Titled(f"Room: {room.name}", 46 | A(Button("Leave Room"), href="/"), 47 | canvas, 48 | Div(color_picker, brush_size), 49 | Script(src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"), 50 | Script(js)) 51 | 52 | serve() -------------------------------------------------------------------------------- /tests/test_toaster.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | from starlette.testclient import TestClient 3 | 4 | app, rt = fast_app() 5 | setup_toasts(app) 6 | cli = TestClient(app) 7 | 8 | @rt("/set-toast-get") 9 | def get(session): 10 | add_toast(session, "Toast get", "info") 11 | return RedirectResponse('/see-toast') 12 | 13 | @rt("/set-toast-post") 14 | def post(session): 15 | add_toast(session, "Toast post", "info") 16 | return RedirectResponse('/see-toast') 17 | 18 | @rt("/see-toast") 19 | def get(session): 20 | return Titled("Hello, world!", P(str(session))) 21 | 22 | @rt("/see-toast-with-typehint") 23 | def get(session: dict): 24 | return Titled("Hello, world!", P(str(session))) 25 | 26 | @rt("/see-toast-ft-response") 27 | def get(session): 28 | add_toast(session, "Toast FtResponse", "info") 29 | return FtResponse(Titled("Hello, world!", P(str(session)))) 30 | 31 | def test_get_toaster(): 32 | cli.get('/set-toast-get', follow_redirects=False) 33 | res = cli.get('/see-toast') 34 | assert 'Toast get' in res.text 35 | 36 | res = cli.get('/set-toast-get', follow_redirects=True) 37 | assert 'Toast get' in res.text 38 | 39 | def test_post_toaster(): 40 | cli.post('/set-toast-post', follow_redirects=False) 41 | res = cli.get('/see-toast') 42 | assert 'Toast post' in res.text 43 | 44 | def test_ft_response(): 45 | res = cli.get('/see-toast-ft-response') 46 | assert 'Toast FtResponse' in res.text 47 | 48 | def test_get_toaster_with_typehint(): 49 | res = cli.get('/see-toast-with-typehint', follow_redirects=False) 50 | assert 'Toast get' in res.text 51 | 52 | res = cli.get('/see-toast-with-typehint', follow_redirects=True) 53 | assert 'Toast get' in res.text 54 | 55 | def test_toast_container_in_response(): 56 | # toasts will not render correctly if the toast container isn't rendered. 57 | res = cli.get('/see-toast-ft-response') 58 | assert 'id="fh-toast-container"' in res.text 59 | 60 | test_get_toaster() 61 | test_post_toaster() 62 | test_ft_response() 63 | test_toast_container_in_response() 64 | -------------------------------------------------------------------------------- /fasthtml/cli.py: -------------------------------------------------------------------------------- 1 | # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/09_cli.ipynb. 2 | 3 | # %% auto 0 4 | __all__ = ['railway_link', 'railway_deploy'] 5 | 6 | # %% ../nbs/api/09_cli.ipynb 7 | from fastcore.utils import * 8 | from fastcore.script import call_parse, bool_arg 9 | from subprocess import check_output, run 10 | 11 | import json 12 | 13 | # %% ../nbs/api/09_cli.ipynb 14 | @call_parse 15 | def railway_link(): 16 | "Link the current directory to the current project's Railway service" 17 | j = json.loads(check_output("railway status --json".split())) 18 | prj = j['id'] 19 | idxpath = 'edges', 0, 'node', 'id' 20 | env = nested_idx(j, 'environments', *idxpath) 21 | svc = nested_idx(j, 'services', *idxpath) 22 | 23 | cmd = f"railway link -e {env} -p {prj} -s {svc}" 24 | res = check_output(cmd.split()) 25 | 26 | # %% ../nbs/api/09_cli.ipynb 27 | def _run(a, **kw): 28 | print('#', ' '.join(a)) 29 | run(a) 30 | 31 | # %% ../nbs/api/09_cli.ipynb 32 | @call_parse 33 | def railway_deploy( 34 | name:str, # The project name to deploy 35 | mount:bool_arg=True # Create a mounted volume at /app/data? 36 | ): 37 | """Deploy a FastHTML app to Railway""" 38 | nm,ver = check_output("railway --version".split()).decode().split() 39 | assert nm.startswith('railway'), f'Unexpected railway version string: {nm}' 40 | if ver2tuple(ver)<(3,8): return print("Please update your railway CLI version to 3.8 or higher") 41 | cp = run("railway status --json".split(), capture_output=True) 42 | if not cp.returncode: 43 | print("Checking deployed projects...") 44 | project_name = json.loads(cp.stdout.decode()).get('name') 45 | if project_name == name: return print("This project is already deployed. Run `railway open`.") 46 | reqs = Path('requirements.txt') 47 | if not reqs.exists(): reqs.write_text('python-fasthtml') 48 | _run(f"railway init -n {name}".split()) 49 | _run(f"railway up -c".split()) 50 | _run(f"railway domain".split()) 51 | railway_link.__wrapped__() 52 | if mount: _run(f"railway volume add -m /app/data".split()) 53 | _run(f"railway up -c".split()) 54 | -------------------------------------------------------------------------------- /examples/todos4.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | 3 | db = database('data/todos.db') 4 | todos = db.t.todos 5 | if todos not in db.t: todos.create(id=int, task=str, done=bool, pk='id') 6 | Todo = todos.dataclass() 7 | 8 | id_curr = 'current-todo' 9 | def tid(id): return f'todo-{id}' 10 | 11 | css = Style(':root { --pico-font-size: 100%; }') 12 | auth = user_pwd_auth(user='s3kret', skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css']) 13 | app = FastHTML(hdrs=(picolink, css), middleware=[auth]) 14 | rt = app.route 15 | 16 | @rt("/{fname:path}.{ext:static}") 17 | def get(fname:str, ext:str): return FileResponse(f'{fname}.{ext}') 18 | 19 | @patch 20 | def __ft__(self:Todo): 21 | show = AX(self.task, f'/todos/{self.id}', id_curr) 22 | edit = AX('edit', f'/edit/{self.id}' , id_curr) 23 | dt = ' (done)' if self.done else '' 24 | return Li(show, dt, ' | ', edit, id=tid(self.id)) 25 | 26 | def mk_input(**kw): return Input(id="new-task", name="task", placeholder="New Todo", **kw) 27 | def clr_details(): return Div(hx_swap_oob='innerHTML', id=id_curr) 28 | 29 | @rt("/") 30 | def get(request): 31 | add = Form(Group(mk_input(), Button("Add")), 32 | hx_post="/", target_id='todo-list', hx_swap="beforeend") 33 | card = Card(Ul(*todos(), id='todo-list'), 34 | header=add, footer=Div(id=id_curr)), 35 | return Titled('Todo list', card) 36 | 37 | @rt("/todos/{id}") 38 | def delete(id:int): 39 | todos.delete(id) 40 | return clr_details() 41 | 42 | @rt("/") 43 | def post(todo:Todo): return todos.insert(todo), mk_input(hx_swap_oob='true') 44 | 45 | @rt("/edit/{id}") 46 | def get(id:int): 47 | res = Form(Group(Input(id="task"), Button("Save")), 48 | Hidden(id="id"), CheckboxX(id="done", label='Done'), 49 | hx_put="/", target_id=tid(id), id="edit") 50 | return fill_form(res, todos[id]) 51 | 52 | @rt("/") 53 | def put(todo: Todo): return todos.update(todo), clr_details() 54 | 55 | @rt("/todos/{id}") 56 | def get(id:int): 57 | todo = todos[id] 58 | btn = Button('delete', hx_delete=f'/todos/{todo.id}', 59 | target_id=tid(todo.id), hx_swap="outerHTML") 60 | return Div(Div(todo.task), btn) 61 | 62 | serve() 63 | 64 | -------------------------------------------------------------------------------- /fasthtml/authmw.py: -------------------------------------------------------------------------------- 1 | import base64, binascii, re 2 | from fasthtml.core import * 3 | from fasthtml.starlette import * 4 | from typing import Mapping 5 | from hmac import compare_digest 6 | 7 | auth_hdrs = {'WWW-Authenticate': 'Basic realm="login"'} 8 | 9 | class BasicAuthMiddleware(MiddlewareBase): 10 | def __init__(self, app, cb, skip=None): self.app,self.cb,self.skip = app,cb,skip or [] 11 | 12 | async def _resp(self, scope, receive, send, resp): 13 | await (send({"type": "websocket.close", "code": 1000}) if scope["type"]=="websocket" else resp(scope, receive, send)) 14 | 15 | async def __call__(self, scope, receive, send) -> None: 16 | conn = await super().__call__(scope, receive, send) 17 | if not conn: return 18 | request = Request(scope, receive) 19 | if not any(re.match(o+'$', request.url.path) for o in self.skip): 20 | res = await self.authenticate(conn) 21 | if not res: res = Response('not authenticated', status_code=401, headers=auth_hdrs) 22 | if isinstance(res, Response): return await self._resp(scope, receive, send, res) 23 | scope["auth"] = res 24 | await self.app(scope, receive, send) 25 | 26 | async def authenticate(self, conn): 27 | if "Authorization" not in conn.headers: return 28 | auth = conn.headers["Authorization"] 29 | try: 30 | scheme, credentials = auth.split() 31 | if scheme.lower() != 'basic': return 32 | decoded = base64.b64decode(credentials).decode("ascii") 33 | except (ValueError, UnicodeDecodeError, binascii.Error) as exc: raise AuthenticationError('Invalid credentials') 34 | user, _, pwd = decoded.partition(":") 35 | if self.cb(user,pwd): return user 36 | 37 | def user_pwd_auth(lookup=None, skip=None, **kwargs): 38 | if isinstance(lookup,Mapping): kwargs = lookup | kwargs 39 | def cb(u,p): 40 | if u=='logout' or not u or not p: return 41 | if callable(lookup): return lookup(u,p) 42 | return compare_digest(kwargs.get(u,'').encode("utf-8"), p.encode("utf-8")) 43 | return Middleware(BasicAuthMiddleware, cb=cb, skip=skip) 44 | 45 | def basic_logout(request): 46 | return f'{request.url.scheme}://logout:logout@{request.headers["host"]}/' 47 | -------------------------------------------------------------------------------- /examples/todos2.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | 3 | id_curr = 'current-todo' 4 | id_list = 'todo-list' 5 | def tid(id): return f'todo-{id}' 6 | 7 | @dataclass 8 | class TodoItem(): 9 | title: str; id: int = -1; done: bool = False 10 | def __ft__(self): 11 | show = AX(self.title, f'/todos/{self.id}', id_curr) 12 | edit = AX('edit', f'/edit/{self.id}' , id_curr) 13 | dt = ' (done)' if self.done else '' 14 | return Li(show, dt, ' | ', edit, id=tid(self.id)) 15 | 16 | TODO_LIST = [TodoItem(id=0, title="Start writing todo list", done=True), 17 | TodoItem(id=1, title="???", done=False), 18 | TodoItem(id=2, title="Profit", done=False)] 19 | 20 | app, rt = fast_app() 21 | 22 | def mk_input(**kw): return Input(id="new-title", name="title", placeholder="New Todo", **kw) 23 | 24 | @app.get("/") 25 | def get_todos(req): 26 | add = Form(hx_post="/", target_id=id_list, hx_swap="beforeend")( 27 | Group(mk_input(), Button("Add")) 28 | ) 29 | card = Card(Ul(*TODO_LIST, id=id_list), 30 | header=add, footer=Div(id=id_curr)), 31 | return Titled('Todo list', card) 32 | 33 | @app.post("/") 34 | def add_item(todo:TodoItem): 35 | todo.id = len(TODO_LIST)+1 36 | TODO_LIST.append(todo) 37 | return todo, mk_input(hx_swap_oob='true') 38 | 39 | def clr_details(): return Div(hx_swap_oob='innerHTML', id=id_curr) 40 | def find_todo(id): return next(o for o in TODO_LIST if o.id==id) 41 | 42 | @app.get("/edit/{id}") 43 | def edit_item(id:int): 44 | todo = find_todo(id) 45 | res = Form(hx_put="/", target_id=tid(id), id="edit")( 46 | Group(Input(id="title"), Button("Save")), 47 | Hidden(id="id"), 48 | CheckboxX(id="done", label='Done'), 49 | ) 50 | return fill_form(res, todo) 51 | 52 | @app.put("/") 53 | def update(todo: TodoItem): 54 | fill_dataclass(todo, find_todo(todo.id)) 55 | return todo, clr_details() 56 | 57 | @app.delete("/todos/{id}") 58 | def del_todo(id:int): 59 | TODO_LIST.remove(find_todo(id)) 60 | return clr_details() 61 | 62 | @app.get("/todos/{id}") 63 | def get_todo(id:int): 64 | todo = find_todo(id) 65 | btn = Button('delete', hx_delete=f'/todos/{todo.id}', 66 | target_id=tid(todo.id), hx_swap="outerHTML") 67 | return Div(Div(todo.title), btn) 68 | 69 | serve() 70 | 71 | -------------------------------------------------------------------------------- /fasthtml/live_reload.py: -------------------------------------------------------------------------------- 1 | from starlette.routing import WebSocketRoute 2 | from fasthtml.basics import FastHTML, Script 3 | 4 | __all__ = ["FastHTMLWithLiveReload"] 5 | 6 | def LiveReloadJs(reload_attempts:int=20, reload_interval:int=1000, **kwargs): 7 | src = """ 8 | (() => { 9 | let attempts = 0; 10 | const connect = () => { 11 | const socket = new WebSocket(`ws://${window.location.host}/live-reload`); 12 | socket.onopen = async() => { 13 | const res = await fetch(window.location.href); 14 | if (res.ok) { 15 | attempts ? window.location.reload() : console.log('LiveReload connected'); 16 | }}; 17 | socket.onclose = () => { 18 | !attempts++ ? connect() : setTimeout(() => { connect() }, %d); 19 | if (attempts > %d) window.location.reload(); 20 | }}; 21 | connect(); 22 | })(); 23 | """ 24 | return Script(src % (reload_attempts, reload_interval)) 25 | 26 | async def live_reload_ws(websocket): await websocket.accept() 27 | 28 | class FastHTMLWithLiveReload(FastHTML): 29 | """ 30 | `FastHTMLWithLiveReload` enables live reloading. 31 | This means that any code changes saved on the server will automatically 32 | trigger a reload of both the server and browser window. 33 | 34 | How does it work? 35 | - a websocket is created at `/live-reload` 36 | - a small js snippet `LIVE_RELOAD_SCRIPT` is injected into each webpage 37 | - this snippet connects to the websocket at `/live-reload` and listens for an `onclose` event 38 | - when the `onclose` event is detected the browser is reloaded 39 | 40 | Why do we listen for an `onclose` event? 41 | When code changes are saved the server automatically reloads if the --reload flag is set. 42 | The server reload kills the websocket connection. The `onclose` event serves as a proxy 43 | for "developer has saved some changes". 44 | 45 | Usage 46 | >>> from fasthtml.common import * 47 | >>> app = FastHTMLWithLiveReload() 48 | 49 | Run: 50 | serve() 51 | """ 52 | def __init__(self, *args, **kwargs): 53 | # "hdrs" and "routes" can be missing, None, a list or a tuple. 54 | kwargs["hdrs"] = [*(kwargs.get("hdrs") or []), LiveReloadJs(**kwargs)] 55 | kwargs["routes"] = [*(kwargs.get("routes") or []), WebSocketRoute("/live-reload", endpoint=live_reload_ws)] 56 | super().__init__(*args, **kwargs) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | client_secret.json 2 | # TODO: modify nbdev to not warn for nb_export nbs 3 | fasthtml/stripe_otp.py 4 | posts/ 5 | .quarto 6 | .sesskey 7 | *.db-* 8 | *.db 9 | .gitattributes 10 | _proc/ 11 | sidebar.yml 12 | Gemfile.lock 13 | token 14 | _docs/ 15 | conda/ 16 | .last_checked 17 | .gitconfig 18 | *.bak 19 | *.log 20 | *~ 21 | ~* 22 | _tmp* 23 | tmp* 24 | tags 25 | 26 | # Byte-compiled / optimized / DLL files 27 | __pycache__/ 28 | *.py[cod] 29 | *$py.class 30 | 31 | # C extensions 32 | *.so 33 | 34 | # Distribution / packaging 35 | .Python 36 | env/ 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | *.egg-info/ 50 | .installed.cfg 51 | *.egg 52 | 53 | # PyInstaller 54 | # Usually these files are written by a python script from a template 55 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 56 | *.manifest 57 | *.spec 58 | 59 | # Installer logs 60 | pip-log.txt 61 | pip-delete-this-directory.txt 62 | 63 | # Unit test / coverage reports 64 | htmlcov/ 65 | .tox/ 66 | .coverage 67 | .coverage.* 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | *.cover 72 | .hypothesis/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Django stuff: 79 | *.log 80 | local_settings.py 81 | 82 | # Flask stuff: 83 | instance/ 84 | .webassets-cache 85 | 86 | # Scrapy stuff: 87 | .scrapy 88 | 89 | # Sphinx documentation 90 | docs/_build/ 91 | 92 | # PyBuilder 93 | target/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # pyenv 99 | .python-version 100 | 101 | # celery beat schedule file 102 | celerybeat-schedule 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # dotenv 108 | .env 109 | 110 | # virtualenv 111 | .venv 112 | venv/ 113 | ENV/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | 128 | .vscode 129 | *.swp 130 | 131 | # osx generated files 132 | .DS_Store 133 | .DS_Store? 134 | .Trashes 135 | ehthumbs.db 136 | Thumbs.db 137 | .idea 138 | 139 | # pytest 140 | .pytest_cache 141 | 142 | # tools/trust-doc-nbs 143 | docs_src/.last_checked 144 | 145 | # symlinks to fastai 146 | docs_src/fastai 147 | tools/fastai 148 | 149 | # link checker 150 | checklink/cookies.txt 151 | 152 | # .gitconfig is now autogenerated 153 | .gitconfig 154 | 155 | _docs 156 | -------------------------------------------------------------------------------- /nbs/ref/live_reload.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Live Reloading" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "\n", 15 | "When building your app it can be useful to view your changes in a web browser as you make them. FastHTML supports live reloading which means that it watches for any changes to your code and automatically refreshes the webpage in your browser.\n", 16 | "\n", 17 | "To enable live reloading simply replace `FastHTML` in your app with `FastHTMLWithLiveReload`.\n", 18 | "\n", 19 | "```python\n", 20 | "from fasthtml.common import *\n", 21 | "app = FastHTMLWithLiveReload()\n", 22 | "```\n", 23 | "\n", 24 | "Then in your terminal run `uvicorn` with reloading enabled.\n", 25 | "\n", 26 | "```\n", 27 | "uvicorn main:app --reload\n", 28 | "```\n", 29 | "\n", 30 | "**⚠️ Gotchas**\n", 31 | "- A reload is only triggered when you save your changes.\n", 32 | "- `FastHTMLWithLiveReload` should only be used during development.\n", 33 | "- If your app spans multiple directories you might need to use the `--reload-dir` flag to watch all files in each directory. See the uvicorn [docs](https://www.uvicorn.org/settings/#development) for more info.\n", 34 | "- The live reload script is only injected into the page when rendering [ft components](https://www.fastht.ml/docs/explains/explaining_xt_components.html)." 35 | ] 36 | }, 37 | { 38 | "cell_type": "markdown", 39 | "metadata": {}, 40 | "source": [ 41 | "## Live reloading with `fast_app`\n", 42 | "\n", 43 | "In development the `fast_app` function provides the same functionality. It instantiates the `FastHTMLWithLiveReload` class if you pass `live=True`:\n", 44 | "\n", 45 | "``` {.python filename=\"main.py\"}\n", 46 | "from fasthtml.common import *\n", 47 | "\n", 48 | "app, rt = fast_app(live=True) # <1>\n", 49 | "\n", 50 | "serve() # <2>\n", 51 | "```\n", 52 | "\n", 53 | "1. `fast_app()` instantiates the `FastHTMLWithLiveReload` class.\n", 54 | "2. `serve()` is a wrapper around a `uvicorn` call.\n", 55 | "\n", 56 | "To run `main.py` in live reload mode, just do `python main.py`. We recommend turning off live reload when deploying your app to production." 57 | ] 58 | } 59 | ], 60 | "metadata": { 61 | "kernelspec": { 62 | "display_name": "python3", 63 | "language": "python", 64 | "name": "python3" 65 | } 66 | }, 67 | "nbformat": 4, 68 | "nbformat_minor": 2 69 | } 70 | -------------------------------------------------------------------------------- /examples/user_app.py: -------------------------------------------------------------------------------- 1 | # Run with: python user_app.py 2 | # At signin, enter a user/pw combination and if it doesn't exist, it will be created. 3 | from fasthtml.common import * 4 | 5 | db = database('data/utodos.db') 6 | class User: name:str; pwd:str 7 | class Todo: id:int; title:str; done:bool; name:str; details:str 8 | users = db.create(User, pk='name') 9 | todos = db.create(Todo) 10 | 11 | id_curr = 'current-todo' 12 | def tid(id): return f'todo-{id}' 13 | 14 | def lookup_user(u,p): 15 | try: user = users[u] 16 | except NotFoundError: user = users.insert(name=u, pwd=p) 17 | return user.pwd==p 18 | 19 | authmw = user_pwd_auth(lookup_user, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css']) 20 | 21 | def before(auth): todos.xtra(name=auth) 22 | 23 | app = FastHTML(middleware=[authmw], before=before, 24 | hdrs=(picolink, 25 | Style(':root { --pico-font-size: 100%; }'))) 26 | rt = app.route 27 | 28 | @rt("/{fname:path}.{ext:static}") 29 | def get(fname:str, ext:str): return FileResponse(f'{fname}.{ext}') 30 | 31 | @patch 32 | def __ft__(self:Todo): 33 | show = AX(self.title, f'/todos/{self.id}', id_curr) 34 | edit = AX('edit', f'/edit/{self.id}' , id_curr) 35 | dt = '✅ ' if self.done else '' 36 | return Li(dt, show, ' | ', edit, Hidden(id="id", value=self.id), id=tid(self.id)) 37 | 38 | def mk_input(**kw): return Input(id="new-title", name="title", placeholder="New Todo", **kw) 39 | def clr_details(): return Div(hx_swap_oob='innerHTML', id=id_curr) 40 | 41 | @rt("/") 42 | def get(request, auth): 43 | add = Form(Group(mk_input(), Button("Add")), 44 | hx_post="/", target_id='todo-list', hx_swap="beforeend") 45 | card = Card(Ul(*todos(), id='todo-list'), 46 | header=add, footer=Div(id=id_curr)), 47 | top = Grid(Div(A('logout', href=basic_logout(request)), style='text-align: right')) 48 | return Titled(f"{auth}'s todo list", top, card) 49 | 50 | @rt("/todos/{id}") 51 | def delete(id:int): 52 | todos.delete(id) 53 | return clr_details() 54 | 55 | @rt("/") 56 | def post(todo:Todo): 57 | return todos.insert(todo), mk_input(hx_swap_oob='true') 58 | 59 | @rt("/edit/{id}") 60 | def get(id:int): 61 | res = Form(Group(Input(id="title"), Button("Save")), 62 | Hidden(id="id"), CheckboxX(id="done", label='Done'), 63 | hx_put="/", target_id=tid(id), id="edit") 64 | return fill_form(res, todos[id]) 65 | 66 | @rt("/") 67 | def put(todo: Todo): 68 | return todos.upsert(todo), clr_details() 69 | 70 | @rt("/todos/{id}") 71 | def get(id:int): 72 | todo = todos[id] 73 | btn = Button('delete', hx_delete=f'/todos/{todo.id}', 74 | target_id=tid(todo.id), hx_swap="outerHTML") 75 | return Div(Div(todo.title), btn) 76 | 77 | serve() 78 | 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import parse_version 2 | from configparser import ConfigParser 3 | import setuptools, shlex 4 | assert parse_version(setuptools.__version__)>=parse_version('36.2') 5 | 6 | # note: all settings are in settings.ini; edit there, not here 7 | config = ConfigParser(delimiters=['=']) 8 | config.read('settings.ini', encoding='utf-8') 9 | cfg = config['DEFAULT'] 10 | 11 | cfg_keys = 'version description keywords author author_email'.split() 12 | expected = cfg_keys + "lib_name user branch license status min_python audience language".split() 13 | for o in expected: assert o in cfg, "missing expected setting: {}".format(o) 14 | setup_cfg = {o:cfg[o] for o in cfg_keys} 15 | 16 | licenses = { 17 | 'apache2': ('Apache Software License 2.0','OSI Approved :: Apache Software License'), 18 | 'mit': ('MIT License', 'OSI Approved :: MIT License'), 19 | 'gpl2': ('GNU General Public License v2', 'OSI Approved :: GNU General Public License v2 (GPLv2)'), 20 | 'gpl3': ('GNU General Public License v3', 'OSI Approved :: GNU General Public License v3 (GPLv3)'), 21 | 'bsd3': ('BSD License', 'OSI Approved :: BSD License'), 22 | } 23 | statuses = [ '1 - Planning', '2 - Pre-Alpha', '3 - Alpha', 24 | '4 - Beta', '5 - Production/Stable', '6 - Mature', '7 - Inactive' ] 25 | py_versions = '3.6 3.7 3.8 3.9 3.10 3.11 3.12 3.13'.split() 26 | 27 | requirements = shlex.split(cfg.get('requirements', '')) 28 | if cfg.get('pip_requirements'): requirements += shlex.split(cfg.get('pip_requirements', '')) 29 | min_python = cfg['min_python'] 30 | lic = licenses.get(cfg['license'].lower(), (cfg['license'], None)) 31 | dev_requirements = (cfg.get('dev_requirements') or '').split() 32 | 33 | setuptools.setup( 34 | name = cfg['lib_name'], 35 | license = lic[0], 36 | classifiers = [ 37 | 'Development Status :: ' + statuses[int(cfg['status'])], 38 | 'Intended Audience :: ' + cfg['audience'].title(), 39 | 'Natural Language :: ' + cfg['language'].title(), 40 | ] + ['Programming Language :: Python :: '+o for o in py_versions[py_versions.index(min_python):]] + (['License :: ' + lic[1] ] if lic[1] else []), 41 | url = cfg['git_url'], 42 | packages = setuptools.find_packages(), 43 | include_package_data = True, 44 | install_requires = requirements, 45 | extras_require={ 'dev': dev_requirements }, 46 | dependency_links = cfg.get('dep_links','').split(), 47 | python_requires = '>=' + cfg['min_python'], 48 | long_description = open('README.md', encoding='utf-8').read(), 49 | long_description_content_type = 'text/markdown', 50 | zip_safe = False, 51 | entry_points = { 52 | 'console_scripts': cfg.get('console_scripts','').split(), 53 | 'nbdev': [f'{cfg.get("lib_path")}={cfg.get("lib_path")}._modidx:d'] 54 | }, 55 | **setup_cfg) 56 | 57 | 58 | -------------------------------------------------------------------------------- /nbs/tutorials/03_quickdraw.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | from datetime import datetime 3 | 4 | def render(room): 5 | return Li(A(room.name, href=f"/rooms/{room.id}")) 6 | 7 | app,rt,rooms,Room = fast_app('data/drawapp.db', render=render, id=int, name=str, created_at=str, canvas_data=str, pk='id') 8 | 9 | @rt("/") 10 | def get(): 11 | create_room = Form(Input(id="name", name="name", placeholder="New Room Name"), 12 | Button("Create Room"), 13 | hx_post="/rooms", hx_target="#rooms-list", hx_swap="afterbegin") 14 | rooms_list = Ul(*rooms(order_by='id DESC'), id='rooms-list') 15 | return Titled("QuickDraw", 16 | create_room, rooms_list) 17 | 18 | @rt("/rooms") 19 | async def post(room:Room): 20 | room.created_at = datetime.now().isoformat() 21 | return rooms.insert(room) 22 | 23 | @rt("/rooms/{id}") 24 | async def get(id:int): 25 | room = rooms[id] 26 | canvas = Canvas(id="canvas", width="800", height="600") 27 | color_picker = Input(type="color", id="color-picker", value="#3CDD8C") 28 | brush_size = Input(type="range", id="brush-size", min="1", max="50", value="10") 29 | save_button = Button("Save Canvas", id="save-canvas", hx_post=f"/rooms/{id}/save", hx_vals="js:{canvas_data: JSON.stringify(canvas.toJSON())}") 30 | 31 | js = f""" 32 | var canvas = new fabric.Canvas('canvas'); 33 | canvas.isDrawingMode = true; 34 | canvas.freeDrawingBrush.color = '#3CDD8C'; 35 | canvas.freeDrawingBrush.width = 10; 36 | 37 | // Load existing canvas data 38 | fetch(`/rooms/{id}/load`) 39 | .then(response => response.json()) 40 | .then(data => {{ 41 | if (data && Object.keys(data).length > 0) {{ 42 | canvas.loadFromJSON(data, canvas.renderAll.bind(canvas)); 43 | }} 44 | }}); 45 | 46 | document.getElementById('color-picker').onchange = function() {{ 47 | canvas.freeDrawingBrush.color = this.value; 48 | }}; 49 | 50 | document.getElementById('brush-size').oninput = function() {{ 51 | canvas.freeDrawingBrush.width = parseInt(this.value, 10); 52 | }}; 53 | """ 54 | 55 | return Titled(f"Room: {room.name}", 56 | A(Button("Leave Room"), href="/"), 57 | canvas, 58 | Div(color_picker, brush_size, save_button), 59 | Script(src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"), 60 | Script(js)) 61 | 62 | @rt("/rooms/{id}/save") 63 | async def post(id:int, canvas_data:str): 64 | rooms.update({'canvas_data': canvas_data}, id) 65 | return "Canvas saved successfully" 66 | 67 | @rt("/rooms/{id}/load") 68 | async def get(id:int): 69 | room = rooms[id] 70 | return room.canvas_data if room.canvas_data else "{}" 71 | 72 | serve() -------------------------------------------------------------------------------- /fasthtml/toaster.py: -------------------------------------------------------------------------------- 1 | from fastcore.xml import FT 2 | from fasthtml.core import FtResponse 3 | from fasthtml.components import * 4 | from fasthtml.xtend import * 5 | 6 | tcid='fh-toast-container' 7 | sk = "toasts" 8 | toast_css = """ 9 | #fh-toast-container { 10 | position: fixed; inset: 20px 0; z-index: 1090; max-width: 80vw; 11 | display: flex; flex-direction: column; align-items: center; 12 | gap: 10px; pointer-events: none; margin: 0 auto; 13 | } 14 | .fh-toast { 15 | background-color: #333; color: white; 16 | padding: 12px 28px 12px 20px; border-radius: 4px; 17 | text-align: center; opacity: 0.9; position: relative; 18 | & > * { pointer-events: auto; }; 19 | transition: opacity 150ms ease-in-out; min-width: 400px; 20 | @starting-style { opacity: 0.2; }; 21 | } 22 | .fh-toast-dismiss { 23 | position: absolute; top: .2em; right: .4em; 24 | line-height: 1rem; padding: 0 .2em .2em .2em; 25 | border-radius: 15%; filter:drop-shadow(0 0 1px black); 26 | background: inherit; color:inherit; 27 | transition: filter 150ms ease-in-out; 28 | filter:brightness(0.8); &:hover { filter:brightness(0.9); } 29 | } 30 | .fh-toast-info { background-color: #2196F3; } 31 | .fh-toast-success { background-color: #4CAF50; } 32 | .fh-toast-warning { background-color: #FF9800; } 33 | .fh-toast-error { background-color: #F44336; } 34 | """ 35 | 36 | js = '''htmx.onLoad(() => { 37 | if (!htmx.find('#fh-toast-container')) { 38 | const ctn = document.createElement('div'); 39 | ctn.id = 'fh-toast-container'; 40 | document.body.appendChild(ctn); 41 | } 42 | });''' 43 | 44 | def Toast(message: str, typ: str = "info", dismiss: bool = False, duration:int=5000): 45 | x_btn = Button('x', cls="fh-toast-dismiss", onclick="htmx.remove(this?.parentElement);") if dismiss else None 46 | return Div(Span(message), x_btn, cls=f"fh-toast fh-toast-{typ}", hx_on_transitionend=f"setTimeout(() => this?.remove(), {duration});") 47 | 48 | def add_toast(sess, message: str, typ: str = "info", dismiss: bool = False): 49 | assert typ in ("info", "success", "warning", "error"), '`typ` not in ("info", "success", "warning", "error")' 50 | sess.setdefault(sk, []).append((message, typ, dismiss)) 51 | 52 | def render_toasts(sess): 53 | toasts = [Toast(msg, typ, dismiss, sess['toast_duration']) for msg, typ, dismiss in sess.pop(sk, [])] 54 | return Div(*toasts, id=tcid, hx_swap_oob=f'beforeend:#{tcid}') 55 | 56 | def toast_after(resp, req, sess): 57 | if sk in sess and (not resp or isinstance(resp, (tuple,FT,FtResponse))): 58 | sess['toast_duration'] = req.app.state.toast_duration 59 | req.injects.append(render_toasts(sess)) 60 | 61 | def setup_toasts(app, duration=5000): 62 | app.state.toast_duration = duration 63 | app.hdrs += [Style(toast_css), Script(js)] 64 | app.after.append(toast_after) 65 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | Examples of unacceptable behavior by participants include: 13 | 14 | * The use of sexualized language or imagery and unwelcome sexual attention or 15 | advances 16 | * Trolling, insulting/derogatory comments, and personal or political attacks 17 | * Public or private harassment 18 | * Publishing others' private information, such as a physical or electronic 19 | address, without explicit permission 20 | 21 | These examples of unacceptable behaviour are requirements; we will not allow them 22 | in any fast.ai project, including this one. 23 | 24 | ## Our Standards 25 | 26 | Examples of behavior that contributes to creating a positive environment 27 | include: 28 | 29 | * Using welcoming and inclusive language 30 | * Being respectful of differing viewpoints and experiences 31 | * Gracefully accepting constructive criticism 32 | * Focusing on what is best for the community 33 | * Showing empathy towards other community members 34 | 35 | These examples are shown only to help you participate effectively -- they are not 36 | requirements, just requests and guidance. 37 | 38 | ## Our Responsibilities 39 | 40 | Project maintainers are responsible for clarifying the standards of acceptable 41 | behavior and are expected to take appropriate and fair corrective action in 42 | response to any instances of unacceptable behavior. 43 | 44 | Project maintainers have the right and responsibility to remove, edit, or 45 | reject comments, commits, code, wiki edits, issues, and other contributions 46 | that are not aligned to this Code of Conduct, or to ban temporarily or 47 | permanently any contributor for other behaviors that they deem inappropriate, 48 | threatening, offensive, or harmful. 49 | 50 | ## Scope 51 | 52 | This Code of Conduct applies both within project spaces and in public spaces 53 | when an individual is representing the project or its community. Examples of 54 | representing a project or community include using an official project e-mail 55 | address, posting via an official social media account or acting as an appointed 56 | representative at an online or offline event. Representation of a project may be 57 | further defined and clarified by project maintainers. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing or otherwise unacceptable behavior may be 62 | reported by contacting the project team at info@fast.ai. All 63 | complaints will be reviewed and investigated and will result in a response that 64 | is deemed necessary and appropriate to the circumstances. The project team is 65 | obligated to maintain confidentiality with regard to the reporter of an incident. 66 | Further details of specific enforcement policies may be posted separately. 67 | 68 | Project maintainers who do not follow or enforce the Code of Conduct in good 69 | faith may face temporary or permanent repercussions as determined by other 70 | members of the project's leadership. 71 | 72 | ## Attribution 73 | 74 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | -------------------------------------------------------------------------------- /fasthtml/pico.py: -------------------------------------------------------------------------------- 1 | """Basic components for generating Pico CSS tags""" 2 | 3 | # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/04_pico.ipynb. 4 | 5 | # %% auto 0 6 | __all__ = ['picocss', 'picolink', 'picocondcss', 'picocondlink', 'set_pico_cls', 'Card', 'Group', 'Search', 'Grid', 'DialogX', 7 | 'Container', 'PicoBusy'] 8 | 9 | # %% ../nbs/api/04_pico.ipynb 10 | from typing import Any 11 | 12 | from fastcore.utils import * 13 | from fastcore.xml import * 14 | from fastcore.meta import use_kwargs, delegates 15 | from .components import * 16 | from .xtend import * 17 | 18 | try: from IPython import display 19 | except ImportError: display=None 20 | 21 | # %% ../nbs/api/04_pico.ipynb 22 | picocss = "https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css" 23 | picolink = (Link(rel="stylesheet", href=picocss), 24 | Style(":root { --pico-font-size: 100%; }")) 25 | picocondcss = "https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.conditional.min.css" 26 | picocondlink = (Link(rel="stylesheet", href=picocondcss), 27 | Style(":root { --pico-font-size: 100%; }")) 28 | 29 | # %% ../nbs/api/04_pico.ipynb 30 | def set_pico_cls(): 31 | js = """var sel = '.cell-output, .output_area'; 32 | document.querySelectorAll(sel).forEach(e => e.classList.add('pico')); 33 | 34 | new MutationObserver(ms => { 35 | ms.forEach(m => { 36 | m.addedNodes.forEach(n => { 37 | if (n.nodeType === 1) { 38 | var nc = n.classList; 39 | if (nc && (nc.contains('cell-output') || nc.contains('output_area'))) nc.add('pico'); 40 | n.querySelectorAll(sel).forEach(e => e.classList.add('pico')); 41 | } 42 | }); 43 | }); 44 | }).observe(document.body, { childList: true, subtree: true });""" 45 | return display.Javascript(js) 46 | 47 | # %% ../nbs/api/04_pico.ipynb 48 | @delegates(ft_hx, keep=True) 49 | def Card(*c, header=None, footer=None, **kwargs)->FT: 50 | "A PicoCSS Card, implemented as an Article with optional Header and Footer" 51 | if header: c = (Header(header),) + c 52 | if footer: c += (Footer(footer),) 53 | return Article(*c, **kwargs) 54 | 55 | # %% ../nbs/api/04_pico.ipynb 56 | @delegates(ft_hx, keep=True) 57 | def Group(*c, **kwargs)->FT: 58 | "A PicoCSS Group, implemented as a Fieldset with role 'group'" 59 | return Fieldset(*c, role="group", **kwargs) 60 | 61 | # %% ../nbs/api/04_pico.ipynb 62 | @delegates(ft_hx, keep=True) 63 | def Search(*c, **kwargs)->FT: 64 | "A PicoCSS Search, implemented as a Form with role 'search'" 65 | return Form(*c, role="search", **kwargs) 66 | 67 | # %% ../nbs/api/04_pico.ipynb 68 | @delegates(ft_hx, keep=True) 69 | def Grid(*c, cls='grid', **kwargs)->FT: 70 | "A PicoCSS Grid, implemented as child Divs in a Div with class 'grid'" 71 | c = tuple(o if isinstance(o,list) else Div(o) for o in c) 72 | return ft_hx('div', *c, cls=cls, **kwargs) 73 | 74 | # %% ../nbs/api/04_pico.ipynb 75 | @delegates(ft_hx, keep=True) 76 | def DialogX(*c, open=None, header=None, footer=None, id=None, **kwargs)->FT: 77 | "A PicoCSS Dialog, with children inside a Card" 78 | card = Card(*c, header=header, footer=footer, **kwargs) 79 | return Dialog(card, open=open, id=id) 80 | 81 | # %% ../nbs/api/04_pico.ipynb 82 | @delegates(ft_hx, keep=True) 83 | def Container(*args, **kwargs)->FT: 84 | "A PicoCSS Container, implemented as a Main with class 'container'" 85 | return Main(*args, cls="container", **kwargs) 86 | 87 | # %% ../nbs/api/04_pico.ipynb 88 | def PicoBusy(): 89 | return (HtmxOn('beforeRequest', "event.detail.elt.setAttribute('aria-busy', 'true' )"), 90 | HtmxOn('afterRequest', "event.detail.elt.setAttribute('aria-busy', 'false')")) 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Make sure you have read the [doc on code style]( 4 | https://docs.fast.ai/dev/style.html) first. (Note that we don't follow PEP8, but instead follow a coding style designed specifically for numerical and interactive programming.) 5 | 6 | This project uses [nbdev](https://nbdev.fast.ai/getting_started.html) for development. Before beginning, make sure that nbdev and a jupyter-compatible client such as jupyterlab or nbclassic are installed. To make changes to the codebase, update the notebooks in the `nbs` folder, not the .py files directly. Then, run `nbdev_export`. For more details, have a look at the [nbdev tutorial](https://nbdev.fast.ai/tutorials/tutorial.html). Depending on the code changes, you might also need to run `tools/update.sh` to update python interface modules and [LLM reference material](https://github.com/AnswerDotAI/llms-txt). 7 | 8 | You may want to set up a `prep` alias in `~/.zshrc` or other shell startup file: 9 | 10 | ```sh 11 | alias prep='nbdev_export && nbdev_clean && nbdev_trust' 12 | ``` 13 | 14 | Run `prep` before each commit to ensure your python files are up to date, and you notebooks cleaned of metadata and notarized. 15 | 16 | ## Updating README.md 17 | 18 | Similar to updating Python source code files, to update the `README.md` file you will need to edit a notebook file, specifically `nbs/index.ipynb`. 19 | 20 | However, there are a couple of extra dependencies that you need to install first in order to make this work properly. Go to the directory you cloned the repo to, and type: 21 | 22 | ``` 23 | pip install -e '.[dev]' 24 | ``` 25 | 26 | And install quarto too: 27 | 28 | ``` 29 | nbdev_install_quarto 30 | ``` 31 | 32 | Then, after you make subsequent changes to `nbs/index.ipynb`, run the following from the repo's root directory to (re)build `README.md`: 33 | 34 | ``` 35 | nbdev_readme 36 | ``` 37 | 38 | ## Did you find a bug? 39 | 40 | * Ensure the bug was not already reported by searching on GitHub under Issues. 41 | * If you're unable to find an open issue addressing the problem, open a new one. Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. 42 | * Be sure to add the complete error messages. 43 | 44 | ### Did you write a patch that fixes a bug? 45 | 46 | * Open a new GitHub pull request with the patch. 47 | * Ensure that your PR includes a test that fails without your patch, and pass with it. 48 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 49 | 50 | ## PR submission guidelines 51 | 52 | * Keep each PR focused. While it's more convenient, do not combine several unrelated fixes together. Create as many branches as needed to keep each PR focused. 53 | * Do not mix style changes/fixes with "functional" changes. It's very difficult to review such PRs and will most likely get rejected. 54 | * Do not add/remove vertical whitespace. Preserve the original style of the file you edit as much as you can. 55 | * Do not turn an already-submitted PR into your development playground. If after you submit a PR, you discover that more work is needed: close the PR, do the required work, and then submit a new PR. Otherwise each of your commits requires attention from maintainers of the project. 56 | * If, however, you submit a PR and receive a request for changes, you should proceed with commits inside that PR, so that the maintainer can see the incremental fixes and won't need to review the whole PR again. In the exception case where you realize it'll take many many commits to complete the requests, then it's probably best to close the PR, do the work, and then submit it again. Use common sense where you'd choose one way over another. 57 | 58 | ## Do you want to contribute to the documentation? 59 | 60 | * Docs are automatically created from the notebooks in the nbs folder. 61 | -------------------------------------------------------------------------------- /examples/adv_app_strip.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | from hmac import compare_digest 3 | 4 | db = database('data/utodos.db') 5 | class User: name:str; pwd:str 6 | class Todo: id:int; title:str; done:bool; name:str; details:str; priority:int 7 | users = db.create(User, pk='name') 8 | todos = db.create(Todo, transform=True) 9 | 10 | login_redir = RedirectResponse('/login', status_code=303) 11 | 12 | def before(req, sess): 13 | auth = req.scope['auth'] = sess.get('auth', None) 14 | if not auth: return login_redir 15 | todos.xtra(name=auth) 16 | 17 | bware = Beforeware(before, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', '/login', '/send_login']) 18 | 19 | def _not_found(req, exc): return Titled('Oh no!', Div('We could not find that page :(')) 20 | app,rt = fast_app(before=bware, exception_handlers={404: _not_found}, 21 | hdrs=(SortableJS('.sortable'), MarkdownJS())) 22 | 23 | @rt 24 | def login(): 25 | frm = Form(action=send_login, method='post')( 26 | Input(id='name', placeholder='Name'), 27 | Input(id='pwd', type='password', placeholder='Password'), 28 | Button('login')) 29 | return Titled("Login", frm) 30 | 31 | @rt 32 | def send_login(name:str, pwd:str, sess): 33 | if not name or not pwd: return login_redir 34 | try: u = users[name] 35 | except NotFoundError: u = users.insert(name=name, pwd=pwd) 36 | if not compare_digest(u.pwd.encode("utf-8"), pwd.encode("utf-8")): return login_redir 37 | sess['auth'] = u.name 38 | return RedirectResponse('/', status_code=303) 39 | 40 | @rt 41 | def logout(sess): 42 | del sess['auth'] 43 | return login_redir 44 | 45 | def clr_details(): return Div(hx_swap_oob='innerHTML', id='current-todo') 46 | 47 | @rt 48 | def update(todo: Todo): return todos.update(todo), clr_details() 49 | 50 | @rt 51 | def edit(id:int): 52 | res = Form(hx_post=update, target_id=f'todo-{id}', id="edit")( 53 | Group(Input(id="title"), Button("Save")), 54 | Hidden(id="id"), CheckboxX(id="done", label='Done'), 55 | Textarea(id="details", name="details", rows=10)) 56 | return fill_form(res, todos[id]) 57 | 58 | @rt 59 | def rm(id:int): 60 | todos.delete(id) 61 | return clr_details() 62 | 63 | @rt 64 | def show(id:int): 65 | todo = todos[id] 66 | btn = Button('delete', hx_post=rm.to(id=todo.id), 67 | hx_target=f'#todo-{todo.id}', hx_swap="outerHTML") 68 | return Div(H2(todo.title), Div(todo.details, cls="marked"), btn) 69 | 70 | @patch 71 | def __ft__(self:Todo): 72 | ashow = AX(self.title, show.to(id=self.id), 'current-todo') 73 | aedit = AX('edit', edit.to(id=self.id), 'current-todo') 74 | dt = '✅ ' if self.done else '' 75 | cts = (dt, ashow, ' | ', aedit, Hidden(id="id", value=self.id), Hidden(id="priority", value="0")) 76 | return Li(*cts, id=f'todo-{self.id}') 77 | 78 | @rt 79 | def create(todo:Todo): 80 | new_inp = Input(id="new-title", name="title", placeholder="New Todo", hx_swap_oob='true') 81 | return todos.insert(todo), new_inp 82 | 83 | @rt 84 | def reorder(id:list[int]): 85 | for i,id_ in enumerate(id): todos.update({'priority':i}, id_) 86 | return tuple(todos(order_by='priority')) 87 | 88 | @rt 89 | def index(auth): 90 | title = f"{auth}'s Todo list" 91 | top = Grid(H1(title), Div(A('logout', href=logout), style='text-align: right')) 92 | new_inp = Input(id="new-title", name="title", placeholder="New Todo") 93 | add = Form(Group(new_inp, Button("Add")), 94 | hx_post=create, target_id='todo-list', hx_swap="afterbegin") 95 | frm = Form(*todos(order_by='priority'), 96 | id='todo-list', cls='sortable', hx_post=reorder, hx_trigger="end") 97 | card = Card(P('Drag/drop todos to reorder them'), 98 | Ul(frm), 99 | header=add, footer=Div(id='current-todo')) 100 | return Title(title), Container(top, card) 101 | 102 | serve() 103 | 104 | -------------------------------------------------------------------------------- /nbs/api/09_cli.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "b73068da", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "#| default_exp cli" 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "id": "27a7e6e2", 16 | "metadata": {}, 17 | "source": [ 18 | "# Command Line Tools" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "id": "acfbc502", 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "#| export\n", 29 | "from fastcore.utils import *\n", 30 | "from fastcore.script import call_parse, bool_arg\n", 31 | "from subprocess import check_output, run\n", 32 | "\n", 33 | "import json" 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": null, 39 | "id": "11d71cfc", 40 | "metadata": {}, 41 | "outputs": [], 42 | "source": [ 43 | "#| export\n", 44 | "@call_parse\n", 45 | "def railway_link():\n", 46 | " \"Link the current directory to the current project's Railway service\"\n", 47 | " j = json.loads(check_output(\"railway status --json\".split()))\n", 48 | " prj = j['id']\n", 49 | " idxpath = 'edges', 0, 'node', 'id'\n", 50 | " env = nested_idx(j, 'environments', *idxpath)\n", 51 | " svc = nested_idx(j, 'services', *idxpath)\n", 52 | "\n", 53 | " cmd = f\"railway link -e {env} -p {prj} -s {svc}\"\n", 54 | " res = check_output(cmd.split())" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "id": "586830f6", 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "#| export\n", 65 | "def _run(a, **kw):\n", 66 | " print('#', ' '.join(a))\n", 67 | " run(a)" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": null, 73 | "id": "b84fd5ff", 74 | "metadata": {}, 75 | "outputs": [], 76 | "source": [ 77 | "#| export\n", 78 | "@call_parse\n", 79 | "def railway_deploy(\n", 80 | " name:str, # The project name to deploy\n", 81 | " mount:bool_arg=True # Create a mounted volume at /app/data?\n", 82 | "):\n", 83 | " \"\"\"Deploy a FastHTML app to Railway\"\"\"\n", 84 | " nm,ver = check_output(\"railway --version\".split()).decode().split()\n", 85 | " assert nm.startswith('railway'), f'Unexpected railway version string: {nm}'\n", 86 | " if ver2tuple(ver)<(3,8): return print(\"Please update your railway CLI version to 3.8 or higher\")\n", 87 | " cp = run(\"railway status --json\".split(), capture_output=True)\n", 88 | " if not cp.returncode:\n", 89 | " print(\"Checking deployed projects...\")\n", 90 | " project_name = json.loads(cp.stdout.decode()).get('name')\n", 91 | " if project_name == name: return print(\"This project is already deployed. Run `railway open`.\")\n", 92 | " reqs = Path('requirements.txt')\n", 93 | " if not reqs.exists(): reqs.write_text('python-fasthtml')\n", 94 | " _run(f\"railway init -n {name}\".split())\n", 95 | " _run(f\"railway up -c\".split())\n", 96 | " _run(f\"railway domain\".split())\n", 97 | " railway_link.__wrapped__()\n", 98 | " if mount: _run(f\"railway volume add -m /app/data\".split())\n", 99 | " _run(f\"railway up -c\".split())" 100 | ] 101 | }, 102 | { 103 | "cell_type": "markdown", 104 | "id": "474e14b4", 105 | "metadata": {}, 106 | "source": [ 107 | "## Export -" 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": null, 113 | "id": "d211e8e2", 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "#| hide\n", 118 | "import nbdev; nbdev.nbdev_export()" 119 | ] 120 | }, 121 | { 122 | "cell_type": "code", 123 | "execution_count": null, 124 | "id": "7b1d43a0", 125 | "metadata": {}, 126 | "outputs": [], 127 | "source": [] 128 | } 129 | ], 130 | "metadata": { 131 | "kernelspec": { 132 | "display_name": "python3", 133 | "language": "python", 134 | "name": "python3" 135 | } 136 | }, 137 | "nbformat": 4, 138 | "nbformat_minor": 5 139 | } 140 | -------------------------------------------------------------------------------- /nbs/llms.txt: -------------------------------------------------------------------------------- 1 | # FastHTML 2 | 3 | > FastHTML is a python library which brings together Starlette, Uvicorn, HTMX, and fastcore's `FT` "FastTags" into a library for creating server-rendered hypermedia applications. The `FastHTML` class itself inherits from `Starlette`, and adds decorator-based routing with many additions, Beforeware, automatic `FT` to HTML rendering, and much more. 4 | 5 | Things to remember when writing FastHTML apps: 6 | 7 | - Although parts of its API are inspired by FastAPI, it is *not* compatible with FastAPI syntax and is not targeted at creating API services 8 | - FastHTML includes support for Pico CSS and the fastlite sqlite library, although using both are optional; sqlalchemy can be used directly or via the fastsql library, and any CSS framework can be used. Support for the Surreal and css-scope-inline libraries are also included, but both are optional 9 | - FastHTML is compatible with JS-native web components and any vanilla JS library, but not with React, Vue, or Svelte 10 | - Use `serve()` for running uvicorn (`if __name__ == "__main__"` is not needed since it's automatic) 11 | - When a title is needed with a response, use `Titled`; note that that already wraps children in `Container`, and already includes both the meta title as well as the H1 element. 12 | 13 | ## Docs 14 | 15 | - [FastHTML concise guide](https://www.fastht.ml/docs/ref/concise_guide.html.md): A brief overview of idiomatic FastHTML apps 16 | - [HTMX reference](https://raw.githubusercontent.com/bigskysoftware/htmx/master/www/content/reference.md): Brief description of all HTMX attributes, CSS classes, headers, events, extensions, js lib methods, and config options 17 | - [Starlette quick guide](https://gist.githubusercontent.com/jph00/e91192e9bdc1640f5421ce3c904f2efb/raw/61a2774912414029edaf1a55b506f0e283b93c46/starlette-quick.md): A quick overview of some Starlette features useful to FastHTML devs. 18 | 19 | ## API 20 | 21 | - [API List](https://www.fastht.ml/docs/apilist.txt): A succint list of all functions and methods in fasthtml. 22 | - [MonsterUI API List](https://raw.githubusercontent.com/AnswerDotAI/MonsterUI/refs/heads/main/docs/apilist.txt): Complete API Reference for Monster UI, a component framework similar to shadcn, but for FastHTML 23 | 24 | 25 | ## Examples 26 | 27 | - [Websockets application](https://raw.githubusercontent.com/AnswerDotAI/fasthtml/main/examples/basic_ws.py): Very brief example of using websockets with HTMX and FastHTML 28 | - [Todo list application](https://raw.githubusercontent.com/AnswerDotAI/fasthtml/main/examples/adv_app.py): Detailed walk-thru of a complete CRUD app in FastHTML showing idiomatic use of FastHTML and HTMX patterns. 29 | 30 | ## Optional 31 | 32 | - [Surreal](https://raw.githubusercontent.com/AnswerDotAI/surreal/main/README.md): Tiny jQuery alternative for plain Javascript with inline Locality of Behavior, providing `me` and `any` functions 33 | - [Starlette full documentation](https://gist.githubusercontent.com/jph00/809e4a4808d4510be0e3dc9565e9cbd3/raw/9b717589ca44cedc8aaf00b2b8cacef922964c0f/starlette-sml.md): A subset of the Starlette documentation useful for FastHTML development. 34 | - [JS App Walkthrough](https://www.fastht.ml/docs/tutorials/e2e.html.md): An end-to-end walkthrough of a complete FastHTML app, including deployment to railway. 35 | - [FastHTML by Example](https://www.fastht.ml/docs/tutorials/by_example.html.md): A collection of 4 FastHTML apps showcasing idiomatic use of FastHTML and HTMX patterns. 36 | - [Using Jupyter to write FastHTML](https://www.fastht.ml/docs/tutorials/jupyter_and_fasthtml.html.md): A guide to developing FastHTML apps inside Jupyter notebooks. 37 | - [FT Components](https://www.fastht.ml/docs/explains/explaining_xt_components.html.md): Explanation of the `FT` components, which are a way to write HTML in a Pythonic way. 38 | - [FAQ](https://www.fastht.ml/docs/explains/faq.html.md): Answers to common questions about FastHTML. 39 | - [MiniDataAPI Spec](https://www.fastht.ml/docs/explains/minidataapi.html.md): Explanation of the MiniDataAPI specification, which allows us to use the same API for many different database engines. 40 | - [OAuth](https://www.fastht.ml/docs/explains/oauth.html.md): Tutorial and explanation of how to use OAuth in FastHTML apps. 41 | - [Routes](https://www.fastht.ml/docs/explains/routes.html.md): Explanation of how routes work in FastHTML. 42 | - [WebSockets](https://www.fastht.ml/docs/explains/websockets.html.md): Explanation of websockets and how they work in FastHTML. 43 | - [Custom Components](https://www.fastht.ml/docs/ref/defining_xt_component.md): Explanation of how to create custom components in FastHTML. 44 | - [Handling Handlers](https://www.fastht.ml/docs/ref/handlers.html.md): Explanation of how to request and response handlers work in FastHTML as routes. 45 | - [Live Reloading](https://www.fastht.ml/docs/ref/live_reload.html.md): Explanation of how to use live reloading for FastHTML development. 46 | -------------------------------------------------------------------------------- /fasthtml/fastapp.py: -------------------------------------------------------------------------------- 1 | """The `fast_app` convenience wrapper""" 2 | 3 | import inspect,uvicorn 4 | from fastcore.utils import * 5 | from fastlite import * 6 | from .basics import * 7 | from .pico import * 8 | from .starlette import * 9 | from .live_reload import FastHTMLWithLiveReload 10 | 11 | __all__ = ['fast_app'] 12 | 13 | def _get_tbl(dt, nm, schema): 14 | render = schema.pop('render', None) 15 | tbl = dt[nm] 16 | if tbl not in dt: tbl.create(**schema) 17 | else: tbl.create(**schema, transform=True) 18 | dc = tbl.dataclass() 19 | if render: dc.__ft__ = render 20 | return tbl,dc 21 | 22 | def _app_factory(*args, **kwargs) -> FastHTML | FastHTMLWithLiveReload: 23 | "Creates a FastHTML or FastHTMLWithLiveReload app instance" 24 | if kwargs.pop('live', False): return FastHTMLWithLiveReload(*args, **kwargs) 25 | kwargs.pop('reload_attempts', None) 26 | kwargs.pop('reload_interval', None) 27 | return FastHTML(*args, **kwargs) 28 | 29 | def fast_app( 30 | db_file:Optional[str]=None, # Database file name, if needed 31 | render:Optional[callable]=None, # Function used to render default database class 32 | hdrs:Optional[tuple]=None, # Additional FT elements to add to 33 | ftrs:Optional[tuple]=None, # Additional FT elements to add to end of 34 | tbls:Optional[dict]=None, # Experimental mapping from DB table names to dict table definitions 35 | before:Optional[tuple]|Beforeware=None, # Functions to call prior to calling handler 36 | middleware:Optional[tuple]=None, # Standard Starlette middleware 37 | live:bool=False, # Enable live reloading 38 | debug:bool=False, # Passed to Starlette, indicating if debug tracebacks should be returned on errors 39 | title:str="FastHTML page", # Default page title 40 | routes:Optional[tuple]=None, # Passed to Starlette 41 | exception_handlers:Optional[dict]=None, # Passed to Starlette 42 | on_startup:Optional[callable]=None, # Passed to Starlette 43 | on_shutdown:Optional[callable]=None, # Passed to Starlette 44 | lifespan:Optional[callable]=None, # Passed to Starlette 45 | default_hdrs=True, # Include default FastHTML headers such as HTMX script? 46 | pico:Optional[bool]=None, # Include PicoCSS header? 47 | surreal:Optional[bool]=True, # Include surreal.js/scope headers? 48 | htmx:Optional[bool]=True, # Include HTMX header? 49 | exts:Optional[list|str]=None, # HTMX extension names to include 50 | canonical:bool=True, # Automatically include canonical link? 51 | secret_key:Optional[str]=None, # Signing key for sessions 52 | key_fname:str='.sesskey', # Session cookie signing key file name 53 | session_cookie:str='session_', # Session cookie name 54 | max_age:int=365*24*3600, # Session cookie expiry time 55 | sess_path:str='/', # Session cookie path 56 | same_site:str='lax', # Session cookie same site policy 57 | sess_https_only:bool=False, # Session cookie HTTPS only? 58 | sess_domain:Optional[str]=None, # Session cookie domain 59 | htmlkw:Optional[dict]=None, # Attrs to add to the HTML tag 60 | bodykw:Optional[dict]=None, # Attrs to add to the Body tag 61 | reload_attempts:Optional[int]=1, # Number of reload attempts when live reloading 62 | reload_interval:Optional[int]=1000, # Time between reload attempts in ms 63 | static_path:str=".", # Where the static file route points to, defaults to root dir 64 | body_wrap:callable=noop_body, # FT wrapper for body contents 65 | nb_hdrs:bool=False, # If in notebook include headers inject headers in notebook DOM? 66 | **kwargs): 67 | "Create a FastHTML or FastHTMLWithLiveReload app." 68 | h = (picolink,) if pico or (pico is None and default_hdrs) else () 69 | if hdrs: h += tuple(hdrs) 70 | 71 | app = _app_factory(hdrs=h, ftrs=ftrs, before=before, middleware=middleware, live=live, debug=debug, title=title, routes=routes, exception_handlers=exception_handlers, 72 | on_startup=on_startup, on_shutdown=on_shutdown, lifespan=lifespan, default_hdrs=default_hdrs, secret_key=secret_key, canonical=canonical, 73 | session_cookie=session_cookie, max_age=max_age, sess_path=sess_path, same_site=same_site, sess_https_only=sess_https_only, 74 | sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmlkw=htmlkw, 75 | reload_attempts=reload_attempts, reload_interval=reload_interval, body_wrap=body_wrap, nb_hdrs=nb_hdrs, **(bodykw or {})) 76 | app.static_route_exts(static_path=static_path) 77 | if not db_file: return app,app.route 78 | 79 | db = database(db_file) 80 | if not tbls: tbls={} 81 | if kwargs: 82 | if isinstance(first(kwargs.values()), dict): tbls = kwargs 83 | else: 84 | kwargs['render'] = render 85 | tbls['items'] = kwargs 86 | dbtbls = [_get_tbl(db.t, k, v) for k,v in tbls.items()] 87 | if len(dbtbls)==1: dbtbls=dbtbls[0] 88 | return app,app.route,*dbtbls 89 | 90 | -------------------------------------------------------------------------------- /fasthtml/js.py: -------------------------------------------------------------------------------- 1 | """Basic external Javascript lib wrappers""" 2 | 3 | # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/03_js.ipynb. 4 | 5 | # %% auto 0 6 | __all__ = ['marked_imp', 'npmcdn', 'light_media', 'dark_media', 'MarkdownJS', 'KatexMarkdownJS', 'HighlightJS', 'SortableJS', 7 | 'MermaidJS'] 8 | 9 | # %% ../nbs/api/03_js.ipynb 10 | import re 11 | from fastcore.utils import * 12 | from fasthtml.components import * 13 | from fasthtml.xtend import * 14 | 15 | # %% ../nbs/api/03_js.ipynb 16 | def light_media( 17 | css: str # CSS to be included in the light media query 18 | ): 19 | "Render light media for day mode views" 20 | return Style('@media (prefers-color-scheme: light) {%s}' %css) 21 | 22 | # %% ../nbs/api/03_js.ipynb 23 | def dark_media( 24 | css: str # CSS to be included in the dark media query 25 | ): 26 | "Render dark media for night mode views" 27 | return Style('@media (prefers-color-scheme: dark) {%s}' %css) 28 | 29 | # %% ../nbs/api/03_js.ipynb 30 | marked_imp = """import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js"; 31 | """ 32 | npmcdn = 'https://cdn.jsdelivr.net/npm/' 33 | 34 | # %% ../nbs/api/03_js.ipynb 35 | def MarkdownJS( 36 | sel='.marked' # CSS selector for markdown elements 37 | ): 38 | "Implements browser-based markdown rendering." 39 | src = "proc_htmx('%s', e => e.innerHTML = marked.parse(e.textContent));" % sel 40 | return Script(marked_imp+src, type='module') 41 | 42 | # %% ../nbs/api/03_js.ipynb 43 | def KatexMarkdownJS( 44 | sel='.marked', # CSS selector for markdown elements 45 | inline_delim='$', # Delimiter for inline math 46 | display_delim='$$', # Delimiter for long math 47 | math_envs=None # List of environments to render as display math 48 | ): 49 | math_envs = math_envs or ['equation', 'align', 'gather', 'multline'] 50 | env_list = '[' + ','.join(f"'{env}'" for env in math_envs) + ']' 51 | fn = Path(__file__).parent/'katex.js' 52 | scr = ScriptX(fn, display_delim=re.escape(display_delim), inline_delim=re.escape(inline_delim), 53 | sel=sel, env_list=env_list, type='module') 54 | css = Link(rel="stylesheet", href=npmcdn+"katex@0.16.11/dist/katex.min.css") 55 | return scr,css 56 | 57 | # %% ../nbs/api/03_js.ipynb 58 | def HighlightJS( 59 | sel='pre code:not([data-highlighted="yes"])', # CSS selector for code elements. Default is industry standard, be careful before adjusting it 60 | langs:str|list|tuple='python', # Language(s) to highlight 61 | light='atom-one-light', # Light theme 62 | dark='atom-one-dark' # Dark theme 63 | ): 64 | "Implements browser-based syntax highlighting. Usage example [here](/tutorials/quickstart_for_web_devs.html#code-highlighting)." 65 | src = """ 66 | hljs.addPlugin(new CopyButtonPlugin()); 67 | hljs.configure({'cssSelector': '%s'}); 68 | htmx.onLoad(hljs.highlightAll);""" % sel 69 | hjs = 'highlightjs','cdn-release', 'build' 70 | hjc = 'arronhunt' ,'highlightjs-copy', 'dist' 71 | if isinstance(langs, str): langs = [langs] 72 | langjs = [jsd(*hjs, f'languages/{lang}.min.js') for lang in langs] 73 | return [jsd(*hjs, f'styles/{dark}.css', typ='css', media="(prefers-color-scheme: dark)"), 74 | jsd(*hjs, f'styles/{light}.css', typ='css', media="(prefers-color-scheme: light)"), 75 | jsd(*hjs, f'highlight.min.js'), 76 | jsd(*hjc, 'highlightjs-copy.min.js'), 77 | jsd(*hjc, 'highlightjs-copy.min.css', typ='css'), 78 | *langjs, Script(src, type='module')] 79 | 80 | # %% ../nbs/api/03_js.ipynb 81 | def SortableJS( 82 | sel='.sortable', # CSS selector for sortable elements 83 | ghost_class='blue-background-class' # When an element is being dragged, this is the class used to distinguish it from the rest 84 | ): 85 | src = """ 86 | import {Sortable} from 'https://cdn.jsdelivr.net/npm/sortablejs/+esm'; 87 | proc_htmx('%s', Sortable.create); 88 | """ % sel 89 | return Script(src, type='module') 90 | 91 | # %% ../nbs/api/03_js.ipynb 92 | def MermaidJS( 93 | sel='.language-mermaid', # CSS selector for mermaid elements 94 | theme='base', # Mermaid theme to use 95 | ): 96 | "Implements browser-based Mermaid diagram rendering." 97 | src = """ 98 | import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'; 99 | 100 | mermaid.initialize({ 101 | startOnLoad: false, 102 | theme: '%s', 103 | securityLevel: 'loose', 104 | flowchart: { useMaxWidth: false, useMaxHeight: false } 105 | }); 106 | 107 | function renderMermaidDiagrams(element, index) { 108 | try { 109 | const graphDefinition = element.textContent; 110 | const graphId = `mermaid-diagram-${index}`; 111 | mermaid.render(graphId, graphDefinition) 112 | .then(({svg, bindFunctions}) => { 113 | element.innerHTML = svg; 114 | bindFunctions?.(element); 115 | }) 116 | .catch(error => { 117 | console.error(`Error rendering Mermaid diagram ${index}:`, error); 118 | element.innerHTML = `

Error rendering diagram: ${error.message}

`; 119 | }); 120 | } catch (error) { 121 | console.error(`Error processing Mermaid diagram ${index}:`, error); 122 | } 123 | } 124 | 125 | // Assuming proc_htmx is a function that triggers rendering 126 | proc_htmx('%s', renderMermaidDiagrams); 127 | """ % (theme, sel) 128 | return Script(src, type='module') 129 | 130 | -------------------------------------------------------------------------------- /nbs/ref/best_practice.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FastHTML Best Practices" 3 | --- 4 | 5 | FastHTML applications are different to applications using FastAPI/react, Django, etc. Don't assume that FastHTML best practices are the same as those for other frameworks. Best practices embody the fast.ai philosophy: remove ceremony, leverage smart defaults, and write code that's both concise and clear. The following are some particular opportunities that both humans and language models sometimes miss: 6 | 7 | ## Database Table Creation 8 | 9 | **Before:** 10 | 11 | ```python 12 | todos = db.t.todos 13 | if not todos.exists(): 14 | todos.create(id=int, task=str, completed=bool, created=str, pk='id') 15 | ``` 16 | 17 | **After:** 18 | 19 | ```python 20 | class Todo: id:int; task:str; completed:bool; created:str 21 | todos = db.create(Todo) 22 | ``` 23 | 24 | FastLite's `create()` is idempotent - it creates the table if needed and returns the table object either way. Using a dataclass-style definition is cleaner and more Pythonic. The `id` field is automatically the primary key. 25 | 26 | ## Route Naming Conventions 27 | 28 | **Before:** 29 | 30 | ```python 31 | @rt("/") 32 | def get(): return Titled("Todo List", ...) 33 | 34 | @rt("/add") 35 | def post(task: str): ... 36 | ``` 37 | 38 | **After:** 39 | 40 | ```python 41 | @rt 42 | def index(): return Titled("Todo List", ...) # Special name for "/" 43 | @rt 44 | def add(task: str): ... # Function name becomes route 45 | ``` 46 | 47 | Use `@rt` without arguments and let the function name define the route. The special name `index` maps to `/`. 48 | 49 | ## Query Parameters over Path Parameters 50 | 51 | **Before:** 52 | 53 | ```python 54 | @rt("/toggle/{todo_id}") 55 | def post(todo_id: int): ... 56 | # URL: /toggle/123 57 | ``` 58 | 59 | **After:** 60 | 61 | ```python 62 | @rt 63 | def toggle(id: int): ... 64 | # URL: /toggle?id=123 65 | ``` 66 | 67 | Query parameters are more idiomatic in FastHTML and avoid duplicating param names in the path. 68 | 69 | ## Leverage Return Values 70 | 71 | :::{.column-body-outset} 72 | 73 | **Before:** 74 | 75 | ```python 76 | @rt 77 | def add(task: str): 78 | new_todo = todos.insert(task=task, completed=False, created=datetime.now().isoformat()) 79 | return todo_item(todos[new_todo]) 80 | 81 | @rt 82 | def toggle(id: int): 83 | todo = todos[id] 84 | todos.update(completed=not todo.completed, id=id) 85 | return todo_item(todos[id]) 86 | ``` 87 | 88 | **After:** 89 | 90 | ```python 91 | @rt 92 | def add(task: str): 93 | return todo_item(todos.insert(task=task, completed=False, created=datetime.now().isoformat())) 94 | 95 | @rt 96 | def toggle(id: int): 97 | return todo_item(todos.update(completed=not todos[id].completed, id=id)) 98 | ``` 99 | 100 | Both `insert()` and `update()` return the affected object, enabling functional chaining. 101 | 102 | ::: 103 | 104 | ## Use `.to()` for URL Generation 105 | 106 | **Before:** 107 | 108 | ```python 109 | hx_post=f"/toggle?id={todo.id}" 110 | ``` 111 | 112 | **After:** 113 | 114 | ```python 115 | hx_post=toggle.to(id=todo.id) 116 | ``` 117 | 118 | The `.to()` method generates URLs with type safety and is refactoring-friendly. 119 | 120 | ## PicoCSS comes free 121 | 122 | **Before:** 123 | 124 | ```python 125 | style = Style(""" 126 | .todo-container { max-width: 600px; margin: 0 auto; padding: 20px; } 127 | /* ... many more lines ... */ 128 | """) 129 | ``` 130 | 131 | **After:** 132 | 133 | ```python 134 | # Just use semantic HTML - Pico styles it automatically 135 | Container(...), Article(...), Card(...), Group(...) 136 | ``` 137 | 138 | `fast_app()` includes PicoCSS by default. Use semantic HTML elements that Pico styles automatically. Use MonsterUI (like shadcn, but for FastHTML) for more complex UI needs. 139 | 140 | ## Smart Defaults 141 | 142 | **Before:** 143 | 144 | ```python 145 | return Titled("Todo List", Container(...)) 146 | 147 | if __name__ == "__main__": 148 | serve() 149 | ``` 150 | 151 | **After:** 152 | 153 | ```python 154 | return Titled("Todo List", ...) # Container is automatic 155 | 156 | serve() # No need for if __name__ guard 157 | ``` 158 | 159 | `Titled` already wraps content in a `Container`, and `serve()` handles the main check internally. 160 | 161 | ## FastHTML Handles Iterables 162 | 163 | **Before:** 164 | 165 | ```python 166 | Section(*[todo_item(todo) for todo in all_todos], id="todo-list") 167 | ``` 168 | 169 | **After:** 170 | 171 | ```python 172 | Section(map(todo_item, all_todos), id="todo-list") 173 | ``` 174 | 175 | FastHTML components accept iterables directly - no need to unpack with `*`. 176 | 177 | ## Functional Patterns 178 | 179 | List comprehensions are great, but `map()` is often cleaner for simple transformations, especially when combined with FastHTML's iterable handling. 180 | 181 | ## Minimal Code 182 | 183 | **Before:** 184 | 185 | ```python 186 | @rt 187 | def delete(id: int): 188 | # Delete from database 189 | todos.delete(id) 190 | # Return empty response 191 | return "" 192 | ``` 193 | 194 | **After:** 195 | 196 | ```python 197 | @rt 198 | def delete(id: int): todos.delete(id) 199 | ``` 200 | 201 | - Skip comments when code is self-documenting 202 | - Don't return empty strings - `None` is returned by default 203 | - Use a single line for a single idea. 204 | 205 | ## Use POST for All Mutations 206 | 207 | **Before:** 208 | 209 | ```python 210 | hx_delete=f"/delete?id={todo.id}" 211 | ``` 212 | 213 | **After:** 214 | 215 | ```python 216 | hx_post=delete.to(id=todo.id) 217 | ``` 218 | 219 | FastHTML routes handle only GET and POST by default. Using only these two verbs is more idiomatic and simpler. 220 | 221 | ## Modern HTMX Event Syntax 222 | 223 | **Before:** 224 | 225 | ```python 226 | hx_on="htmx:afterRequest: this.reset()" 227 | ``` 228 | 229 | **After:** 230 | 231 | ```python 232 | hx_on__after_request="this.reset()" 233 | ``` 234 | 235 | This works because: 236 | 237 | - `hx-on="event: code"` is deprecated; `hx-on-event="code"` is preferred 238 | - FastHTML converts `_` to `-` (so `hx_on__after_request` becomes `hx-on--after-request`) 239 | - `::` in HTMX can be used as a shortcut for `:htmx:`. 240 | - HTMX natively accepts `-` instead of `:` (so `-htmx-` works like `:htmx:`) 241 | - HTMX accepts e.g `after-request` as an alternative to camelCase `afterRequest` 242 | 243 | -------------------------------------------------------------------------------- /nbs/tutorials/jupyter_and_fasthtml.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "2a2294a7", 6 | "metadata": {}, 7 | "source": [ 8 | "# Using Jupyter to write FastHTML\n", 9 | "\n", 10 | "> Writing FastHTML applications in Jupyter notebooks requires a slightly different process than normal Python applications.\n", 11 | "\n", 12 | "- skip_exec: true" 13 | ] 14 | }, 15 | { 16 | "cell_type": "markdown", 17 | "id": "4b1ee344", 18 | "metadata": {}, 19 | "source": [ 20 | "Writing FastHTML applications in Jupyter notebooks requires a slightly different process than normal Python applications. \n", 21 | "\n", 22 | ":::{.callout-tip title='Download this notebook and try it yourself'}\n", 23 | "The source code for this page is a [Jupyter notebook](https://github.com/AnswerDotAI/fasthtml/blob/main/nbs/tutorials/jupyter_and_fasthtml.ipynb). That makes it easy to directly experiment with it. However, as this is working code that means we have to comment out a few things in order for the documentation to build.\n", 24 | ":::\n", 25 | "\n", 26 | "The first step is to import necessary libraries. As using FastHTML inside a Jupyter notebook is a special case, it remains a special import. " 27 | ] 28 | }, 29 | { 30 | "cell_type": "code", 31 | "execution_count": null, 32 | "id": "e244a34a", 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "from fasthtml.common import *\n", 37 | "from fasthtml.jupyter import JupyUvi, HTMX" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "id": "7f38039a", 43 | "metadata": {}, 44 | "source": [ 45 | "Let's create an app with `fast_app`." 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "id": "9ac00db7", 52 | "metadata": {}, 53 | "outputs": [], 54 | "source": [ 55 | "app, rt = fast_app(pico=True)" 56 | ] 57 | }, 58 | { 59 | "cell_type": "markdown", 60 | "id": "d24f2972", 61 | "metadata": {}, 62 | "source": [ 63 | "Define a route to test the application." 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "id": "ea60bc88", 70 | "metadata": {}, 71 | "outputs": [], 72 | "source": [ 73 | "@rt\n", 74 | "def index():\n", 75 | " return Titled('Hello, Jupyter',\n", 76 | " P('Welcome to the FastHTML + Jupyter example'),\n", 77 | " Button('Click', hx_get='/click', hx_target='#dest'),\n", 78 | " Div(id='dest')\n", 79 | " )" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "id": "e512dbab", 85 | "metadata": {}, 86 | "source": [ 87 | "Create a `server` object using `JupyUvi`, which also starts Uvicorn. The `server` runs in a separate thread from Jupyter, so it can use normal HTTP client functions in a notebook." 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": null, 93 | "id": "3b815c60", 94 | "metadata": {}, 95 | "outputs": [ 96 | { 97 | "data": { 98 | "text/html": [ 99 | "\n", 100 | "" 107 | ], 108 | "text/plain": [ 109 | "" 110 | ] 111 | }, 112 | "metadata": {}, 113 | "output_type": "display_data" 114 | } 115 | ], 116 | "source": [ 117 | "server = JupyUvi(app)" 118 | ] 119 | }, 120 | { 121 | "cell_type": "markdown", 122 | "id": "7341b10a", 123 | "metadata": {}, 124 | "source": [ 125 | "The `HTMX` callable displays the server's HTMX application in an iframe which can be displayed by Jupyter notebook. Pass in the same `port` variable used in the `JupyUvi` callable above or leave it blank to use the default (8000).\n" 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": null, 131 | "id": "e7d4e6fa", 132 | "metadata": {}, 133 | "outputs": [], 134 | "source": [ 135 | "# This doesn't display in the docs - uncomment and run it to see it in action\n", 136 | "# HTMX()" 137 | ] 138 | }, 139 | { 140 | "cell_type": "markdown", 141 | "id": "dd772e31", 142 | "metadata": {}, 143 | "source": [ 144 | "We didn't define the `/click` route, but that's fine - we can define (or change) it any time, and it's dynamically inserted into the running app. No need to restart or reload anything!" 145 | ] 146 | }, 147 | { 148 | "cell_type": "code", 149 | "execution_count": null, 150 | "id": "3ec36d1e", 151 | "metadata": {}, 152 | "outputs": [], 153 | "source": [ 154 | "@rt\n", 155 | "def click(): return P('You clicked me!')" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "id": "72fc8a55", 161 | "metadata": {}, 162 | "source": [ 163 | "## Full screen view" 164 | ] 165 | }, 166 | { 167 | "cell_type": "markdown", 168 | "id": "4cb6bb90", 169 | "metadata": {}, 170 | "source": [ 171 | "You can view your app outside of Jupyter by going to `localhost:PORT`, where `PORT` is usually the default 8000, so in most cases just click [this link](localhost:8000/)." 172 | ] 173 | }, 174 | { 175 | "cell_type": "markdown", 176 | "id": "b231b0b0", 177 | "metadata": {}, 178 | "source": [ 179 | "## Graceful shutdowns" 180 | ] 181 | }, 182 | { 183 | "cell_type": "markdown", 184 | "id": "86554c9d", 185 | "metadata": {}, 186 | "source": [ 187 | "Use the `server.stop()` function displayed below. If you restart Jupyter without calling this line the thread may not be released and the `HTMX` callable above may throw errors. If that happens, a quick temporary fix is to specify a different port number in JupyUvi and HTMX with the `port` parameter.\n", 188 | "\n", 189 | "Cleaner solutions to the dangling thread are to kill the dangling thread (dependant on each operating system) or restart the computer." 190 | ] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "id": "6650d9bc", 196 | "metadata": {}, 197 | "outputs": [], 198 | "source": [ 199 | "server.stop()" 200 | ] 201 | } 202 | ], 203 | "metadata": { 204 | "kernelspec": { 205 | "display_name": "python3", 206 | "language": "python", 207 | "name": "python3" 208 | } 209 | }, 210 | "nbformat": 4, 211 | "nbformat_minor": 5 212 | } 213 | -------------------------------------------------------------------------------- /examples/pep8_app.py: -------------------------------------------------------------------------------- 1 | ### 2 | # Walkthrough of an idiomatic fasthtml app, made PEP-8 et al friendly 3 | ### 4 | 5 | # This fasthtml app includes functionality from fastcore, starlette, fastlite, and fasthtml itself. 6 | # Run with: `python adv_app.py` 7 | from fasthtml import common as fh 8 | from hmac import compare_digest 9 | from dataclasses import dataclass 10 | from fastcore.utils import patch 11 | 12 | 13 | class User: 14 | name: str 15 | pwd: str 16 | 17 | 18 | class Todo: 19 | id: int 20 | title: str 21 | done: bool 22 | name: str 23 | details: str 24 | priority: int 25 | 26 | 27 | db = fh.database('data/todos_p8.db') 28 | users = db.create(User, pk='name') 29 | todos = db.create(Todo) 30 | 31 | # Any Starlette response class can be returned by a FastHTML route handler. 32 | login_redir = fh.RedirectResponse('/login', status_code=303) 33 | 34 | 35 | # The `before` function is a *Beforeware* function, that runs before a handler. 36 | def before(req, sess): 37 | # This sets the `auth` attribute in the request scope, and gets it from the session. 38 | auth = req.scope['auth'] = sess.get('auth', None) 39 | if not auth: return login_redir 40 | # `xtra` is part of the MiniDataAPI spec. It adds a filter to queries and DDL statements 41 | todos.xtra(name=auth) 42 | 43 | 44 | bware = fh.Beforeware(before, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', '/login']) 45 | # The `FastHTML` class is a subclass of `Starlette`, so you can use any parameters that `Starlette` accepts. 46 | app, rt = fh.fast_app(before=bware, hdrs=(fh.SortableJS('.sortable'), fh.KatexMarkdownJS(sel='.markdown'))) 47 | 48 | 49 | @app.get("/login") 50 | def login(): 51 | # This creates a form with two input fields, and a submit button. 52 | frm = fh.Form(action='/login', method='post')( 53 | fh.Input(id='name', placeholder='Name'), 54 | fh.Input(id='pwd', type='password', placeholder='Password'), 55 | fh.Button('login'), 56 | ) 57 | return fh.Titled("Login", frm) 58 | 59 | 60 | # Handlers are passed whatever information they "request" in the URL, as keyword arguments. 61 | @dataclass 62 | class Login: 63 | name: str 64 | pwd: str 65 | 66 | 67 | # This handler is called when a POST request is made to the `/login` path. 68 | @app.post("/login") 69 | def login_post(login: Login, sess): 70 | if not login.name or not login.pwd: return login_redir 71 | # Indexing into a MiniDataAPI table queries by primary key, which is `name` here. 72 | try: 73 | u = users[login.name] 74 | except fh.NotFoundError: 75 | u = users.insert(login) 76 | if not compare_digest(u.pwd.encode("utf-8"), login.pwd.encode("utf-8")): return login_redir 77 | # Because the session is signed, we can securely add information to it. 78 | sess['auth'] = u.name 79 | return fh.RedirectResponse('/', status_code=303) 80 | 81 | 82 | @app.get("/logout") 83 | def logout(sess): 84 | del sess['auth'] 85 | return login_redir 86 | 87 | 88 | # FastHTML uses Starlette's path syntax, and adds a `static` type. 89 | @app.get("/{fname:path}.{ext:static}") 90 | def static(fname: str, ext: str): 91 | return fh.FileResponse(f'{fname}.{ext}') 92 | 93 | 94 | # The `patch` decorator, which is defined in `fastcore`, adds a method to an existing class. 95 | # The `__ft__` method is a method that FastHTML uses to convert the object into an `FT` 96 | @patch 97 | def __ft__(self: Todo): 98 | # Some FastHTML tags have an 'X' suffix, which means they're "extended" in some way. 99 | show = fh.AX(self.title, f'/todos/{self.id}', 'current-todo') 100 | edit = fh.AX('edit', f'/edit/{self.id}', 'current-todo') 101 | dt = '✅ ' if self.done else '' 102 | cts = (dt, show, ' | ', edit, fh.Hidden(id="id", value=self.id), fh.Hidden(id="priority", value="0")) 103 | # Any FT object can take a list of children as positional args, and a dict of attrs as keyword args. 104 | return fh.Li(*cts, id=f'todo-{self.id}') 105 | 106 | 107 | # This is the handler for the main todo list application. 108 | @app.get("/") 109 | def get(auth): 110 | title = f"{auth}'s Todo list" 111 | top = fh.Grid(fh.H1(title), fh.Div(fh.A('logout', href='/logout'), style='text-align: right')) 112 | new_inp = fh.Input(id="new-title", name="title", placeholder="New Todo") 113 | grp = fh.Group(new_inp, fh.Button("Add")) 114 | add = fh.Form(grp, hx_post="/", target_id='todo-list', hx_swap="afterbegin") 115 | # In the MiniDataAPI spec, treating a table as a callable (i.e with `todos(...)` here) queries the table. 116 | frm = fh.Form(*todos(order_by='priority'), id='todo-list', cls='sortable', hx_post="/reorder", hx_trigger="end") 117 | # We create an empty 'current-todo' Div at the bottom of our page, as a target for the details and editing views. 118 | card = fh.Card(fh.Ul(frm), header=add, footer=fh.Div(id='current-todo')) 119 | # PicoCSS uses `
` page content; `Container` is a tiny function that generates that. 120 | return fh.Title(title), fh.Container(top, card) 121 | 122 | 123 | # This is the handler for the reordering of todos. 124 | # It's a POST request, which is used by the 'sortable' js library. 125 | @app.post("/reorder") 126 | def reorder(id: list[int]): 127 | for i, id_ in enumerate(id): 128 | todos.update({'priority': i}, id_) 129 | return tuple(todos(order_by='priority')) 130 | 131 | 132 | def clr_details(): 133 | return fh.Div(hx_swap_oob='innerHTML', id='current-todo') 134 | 135 | 136 | # This route handler uses a path parameter `{id}` which is automatically parsed and passed as an int. 137 | @app.delete("/todos/{id}") 138 | def delete(id: int): 139 | todos.delete(id) 140 | # Returning `clr_details()` ensures the details view is cleared after deletion. 141 | # Note that we are not returning *any* FT component that doesn't have an "OOB" swap 142 | return clr_details() 143 | 144 | 145 | @app.get("/edit/{id}") 146 | def edit(id: int): 147 | # The `hx_put` attribute tells HTMX to send a PUT request when the form is submitted. 148 | res = fh.Form(hx_put="/", target_id=f'todo-{id}', id="edit")( 149 | fh.Group(fh.Input(id="title"), fh.Button("Save")), 150 | fh.Hidden(id="id"), 151 | fh.CheckboxX(id="done", label='Done'), 152 | fh.Textarea(id="details", name="details", rows=10), 153 | ) 154 | # `fill_form` populates the form with existing todo data, and returns the result. 155 | return fh.fill_form(res, todos[id]) 156 | 157 | 158 | @app.put("/") 159 | def put(todo: Todo): 160 | return todos.upsert(todo), clr_details() 161 | 162 | 163 | @app.post("/") 164 | def post(todo: Todo): 165 | # This is used to clear the input field after adding the new todo. 166 | new_inp = fh.Input(id="new-title", name="title", placeholder="New Todo", hx_swap_oob='true') 167 | # `insert` returns the inserted todo, which is appended to the start of the list. 168 | return todos.insert(todo), new_inp 169 | 170 | 171 | @app.get("/todos/{id}") 172 | def get_todo(id: int): 173 | todo = todos[id] 174 | btn = fh.Button('delete', hx_delete=f'/todos/{todo.id}', target_id=f'todo-{todo.id}', hx_swap="outerHTML") 175 | # The "markdown" class is used here because that's the CSS selector we used in the JS earlier. 176 | # Because `class` is a reserved keyword in Python, we use `cls` instead, which FastHTML auto-converts. 177 | return fh.Div(fh.H2(todo.title), fh.Div(todo.details, cls="markdown"), btn) 178 | 179 | 180 | fh.serve() 181 | -------------------------------------------------------------------------------- /fasthtml/jupyter.py: -------------------------------------------------------------------------------- 1 | """Use FastHTML in Jupyter notebooks""" 2 | 3 | # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/06_jupyter.ipynb. 4 | 5 | # %% auto 0 6 | __all__ = ['nb_serve', 'nb_serve_async', 'is_port_free', 'wait_port_free', 'show', 'render_ft', 'htmx_config_port', 'JupyUvi', 7 | 'JupyUviAsync', 'HTMX', 'ws_client'] 8 | 9 | # %% ../nbs/api/06_jupyter.ipynb 10 | import asyncio, socket, time, uvicorn 11 | from threading import Thread 12 | from fastcore.utils import * 13 | from fastcore.meta import delegates 14 | from .common import * 15 | from .common import show as _show 16 | from fastcore.parallel import startthread 17 | try: from IPython.display import HTML,Markdown,display 18 | except ImportError: pass 19 | 20 | # %% ../nbs/api/06_jupyter.ipynb 21 | def nb_serve(app, log_level="error", port=8000, host='0.0.0.0', **kwargs): 22 | "Start a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`" 23 | server = uvicorn.Server(uvicorn.Config(app, log_level=log_level, host=host, port=port, **kwargs)) 24 | async def async_run_server(server): await server.serve() 25 | @startthread 26 | def run_server(): asyncio.run(async_run_server(server)) 27 | while not server.started: time.sleep(0.01) 28 | return server 29 | 30 | # %% ../nbs/api/06_jupyter.ipynb 31 | async def nb_serve_async(app, log_level="error", port=8000, host='0.0.0.0', **kwargs): 32 | "Async version of `nb_serve`" 33 | server = uvicorn.Server(uvicorn.Config(app, log_level=log_level, host=host, port=port, **kwargs)) 34 | asyncio.get_running_loop().create_task(server.serve()) 35 | while not server.started: await asyncio.sleep(0.01) 36 | return server 37 | 38 | # %% ../nbs/api/06_jupyter.ipynb 39 | def is_port_free(port, host='localhost'): 40 | "Check if `port` is free on `host`" 41 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 42 | try: 43 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 44 | sock.bind((host, port)) 45 | return True 46 | except OSError: return False 47 | finally: sock.close() 48 | 49 | # %% ../nbs/api/06_jupyter.ipynb 50 | def wait_port_free(port, host='localhost', max_wait=3): 51 | "Wait for `port` to be free on `host`" 52 | start_time = time.time() 53 | while not is_port_free(port): 54 | if time.time() - start_time>max_wait: return print(f"Timeout") 55 | time.sleep(0.1) 56 | 57 | # %% ../nbs/api/06_jupyter.ipynb 58 | @delegates(_show) 59 | def show(*s, **kwargs): 60 | "Same as fasthtml.components.show, but also adds `htmx.process()`" 61 | if IN_NOTEBOOK: return _show(*s, Script('if (window.htmx) htmx.process(document.body)'), **kwargs) 62 | return _show(*s, **kwargs) 63 | 64 | # %% ../nbs/api/06_jupyter.ipynb 65 | def render_ft(**kw): 66 | "Call once in a notebook or solveit dialog to auto-render components" 67 | @patch 68 | def _repr_markdown_(self:FT): 69 | scr_proc = Script('if (window.htmx) htmx.process(document.body)') 70 | return to_xml(Div(self, scr_proc, **kw)) 71 | 72 | # %% ../nbs/api/06_jupyter.ipynb 73 | def htmx_config_port(port=8000): 74 | display(HTML(''' 75 | ''' % port)) 82 | 83 | # %% ../nbs/api/06_jupyter.ipynb 84 | class JupyUvi: 85 | "Start and stop a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`" 86 | def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True, **kwargs): 87 | self.kwargs = kwargs 88 | store_attr(but='start') 89 | self.server = None 90 | if start: self.start() 91 | if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port) 92 | 93 | def start(self): 94 | self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port, **self.kwargs) 95 | 96 | async def start_async(self): 97 | self.server = await nb_serve_async(self.app, log_level=self.log_level, host=self.host, port=self.port, **self.kwargs) 98 | 99 | def stop(self): 100 | self.server.should_exit = True 101 | wait_port_free(self.port) 102 | 103 | # %% ../nbs/api/06_jupyter.ipynb 104 | class JupyUviAsync(JupyUvi): 105 | "Start and stop an async Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`" 106 | def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, **kwargs): 107 | super().__init__(app, log_level=log_level, host=host, port=port, start=False, **kwargs) 108 | 109 | async def start(self): 110 | self.server = await nb_serve_async(self.app, log_level=self.log_level, host=self.host, port=self.port, **self.kwargs) 111 | 112 | def stop(self): 113 | self.server.should_exit = True 114 | wait_port_free(self.port) 115 | 116 | # %% ../nbs/api/06_jupyter.ipynb 117 | from starlette.testclient import TestClient 118 | from html import escape 119 | 120 | # %% ../nbs/api/06_jupyter.ipynb 121 | def HTMX(path="/", host='localhost', app=None, port=8000, height="auto", link=False, iframe=True): 122 | "An iframe which displays the HTMX application in a notebook." 123 | if isinstance(height, int): height = f"{height}px" 124 | scr = """{ 125 | let frame = this; 126 | window.addEventListener('message', function(e) { 127 | if (e.source !== frame.contentWindow) return; // Only proceed if the message is from this iframe 128 | if (e.data.height) frame.style.height = (e.data.height+1) + 'px'; 129 | }, false); 130 | }""" if height == "auto" else "" 131 | proto = 'http' if host=='localhost' else 'https' 132 | fullpath = f"{proto}://{host}:{port}{path}" if host else path 133 | src = f'src="{fullpath}"' 134 | if link: display(HTML(f'Open in new tab')) 135 | if isinstance(path, (FT,tuple,Safe)): 136 | assert app, 'Need an app to render a component' 137 | route = f'/{unqid()}' 138 | res = path 139 | app.get(route)(lambda: res) 140 | page = TestClient(app).get(route).text 141 | src = f'srcdoc="{escape(page)}"' 142 | if iframe: 143 | return HTML(f' """) 144 | 145 | # %% ../nbs/api/06_jupyter.ipynb 146 | def ws_client(app, nm='', host='localhost', port=8000, ws_connect='/ws', frame=True, link=True, **kwargs): 147 | path = f'/{nm}' 148 | c = Main('', cls="container", id=unqid()) 149 | @app.get(path) 150 | def f(): 151 | return Div(c, id=nm or '_dest', hx_trigger='load', 152 | hx_ext="ws", ws_connect=ws_connect, **kwargs) 153 | if link: display(HTML(f'open in browser')) 154 | if frame: display(HTMX(path, host=host, port=port)) 155 | def send(o): asyncio.create_task(app._send(o)) 156 | c.on(send) 157 | return c 158 | -------------------------------------------------------------------------------- /nbs/explains/faq.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# FAQ\n", 8 | "> Frequently Asked Questions" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "metadata": {}, 14 | "source": [ 15 | "## Why does my editor say that I have errors in my FastHTML code?" 16 | ] 17 | }, 18 | { 19 | "cell_type": "markdown", 20 | "metadata": {}, 21 | "source": [ 22 | "Many editors, including Visual Studio Code, use PyLance to provide error checking for Python. However, PyLance's error checking is just a guess -- it can't actually know whether your code is correct or not. PyLance particularly struggles with FastHTML's syntax, which leads to it often reporting false error messages in FastHTML projects.\n", 23 | "\n", 24 | "To avoid these misleading error messages, it's best to disable some PyLance error checking in your FastHTML projects. Here's how to do it in Visual Studio Code (the same approach should also work in other editors based on vscode, such as Cursor and GitHub Codespaces):\n", 25 | "\n", 26 | "1. Open your FastHTML project\n", 27 | "2. Press `Ctrl+Shift+P` (or `Cmd+Shift+P` on Mac) to open the Command Palette\n", 28 | "3. Type \"Preferences: Open Workspace Settings (JSON)\" and select it\n", 29 | "4. In the JSON file that opens, add the following lines:\n", 30 | "```json\n", 31 | "{\n", 32 | " \"python.analysis.diagnosticSeverityOverrides\": {\n", 33 | " \"reportGeneralTypeIssues\": \"none\",\n", 34 | " \"reportOptionalMemberAccess\": \"none\",\n", 35 | " \"reportWildcardImportFromLibrary\": \"none\",\n", 36 | " \"reportRedeclaration\": \"none\",\n", 37 | " \"reportAttributeAccessIssue\": \"none\",\n", 38 | " \"reportInvalidTypeForm\": \"none\",\n", 39 | " \"reportAssignmentType\": \"none\",\n", 40 | " }\n", 41 | "}\n", 42 | "```\n", 43 | "5. Save the file\n", 44 | "\n", 45 | "Even with PyLance diagnostics turned off, your FastHTML code will still run correctly. If you're still seeing some false errors from PyLance, you can disable it entirely by adding this to your settings:\n", 46 | "\n", 47 | "```json\n", 48 | "{\n", 49 | " \"python.analysis.ignore\": [ \"*\" ]\n", 50 | "}\n", 51 | "```" 52 | ] 53 | }, 54 | { 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "## Why the distinctive coding style?" 59 | ] 60 | }, 61 | { 62 | "cell_type": "markdown", 63 | "metadata": {}, 64 | "source": [ 65 | "FastHTML coding style is the [fastai coding style](https://docs.fast.ai/dev/style.html). \n", 66 | "\n", 67 | "If you are coming from a data science background the **fastai coding style** may already be your preferred style.\n", 68 | "\n", 69 | "If you are coming from a PEP-8 background where the use of ruff is encouraged, there is a learning curve. However, once you get used to the **fastai coding style** you may discover yourself appreciating the concise nature of this style. It also encourages using more functional programming tooling, which is both productive and fun. Having said that, it's entirely optional!" 70 | ] 71 | }, 72 | { 73 | "cell_type": "markdown", 74 | "metadata": {}, 75 | "source": [ 76 | "## Why not JSX?" 77 | ] 78 | }, 79 | { 80 | "cell_type": "markdown", 81 | "metadata": {}, 82 | "source": [ 83 | "Many have asked! We think there's no benefit... Python's positional and kw args precisely 1:1 map already to html/xml children and attrs, so there's no need for a new syntax.\n", 84 | "\n", 85 | "We wrote some more thoughts on Why Python HTML components over Jinja2, Mako, or JSX [here](https://www.answer.ai/posts/2024-08-03-fasthtml.html#why)." 86 | ] 87 | }, 88 | { 89 | "cell_type": "markdown", 90 | "metadata": {}, 91 | "source": [ 92 | "## Why use `import *`" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "First, through the use of the [`__all__`](https://docs.python.org/3/tutorial/modules.html#importing-from-a-package) attribute in our Python modules we control what actually gets imported. So there's no risk of namespace pollution.\n", 100 | "\n", 101 | "Second, our style lends itself to working in rather compact Jupyter notebooks and small Python modules. Hence we know about the source code whose libraries we `import *` from. This terseness means we can develop faster. We're a small team, and any edge we can gain is important to us. \n", 102 | "\n", 103 | "Third, for external libraries, be it core Python, SQLAlchemy, or other things we do tend to use explicit imports. In part to avoid namespace collisions, and also as reference to know where things are coming from.\n", 104 | "\n", 105 | "We'll finish by saying a lot of our users employ explicit imports. If that's the path you want to take, we encourage the use of `from fasthtml import common as fh`. The acronym of `fh` makes it easy to recognize that a symbol is from the FastHTML library." 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "## Can FastHTML be used for dashboards?\n", 113 | "\n", 114 | "Yes it can. In fact, it excels at building dashboards. In addition to being great for building static dashboards, because of its [foundation](https://fastht.ml/about/foundation) in ASGI and [tech stack](https://fastht.ml/about/tech), FastHTML natively supports Websockets. That means using FastHTML we can create dashboards that autoupdate. " 115 | ] 116 | }, 117 | { 118 | "cell_type": "markdown", 119 | "metadata": {}, 120 | "source": [ 121 | "## Why is FastHTML developed using notebooks?" 122 | ] 123 | }, 124 | { 125 | "cell_type": "markdown", 126 | "metadata": {}, 127 | "source": [ 128 | "Some people are under the impression that writing software in notebooks is bad.\n", 129 | "\n", 130 | "[Watch this video](https://www.youtube.com/watch?v=9Q6sLbz37gk&ab_channel=JeremyHoward). We've used Jupyter notebooks exported via `nbdev` to write a wide range of “very serious” software projects over the last three years. This includes deep learning libraries, API clients, Python language extensions, terminal user interfaces, web frameworks, and more!\n", 131 | "\n", 132 | "[nbdev](https://nbdev.fast.ai/) is a Jupyter-powered tool for writing software. Traditional programming environments throw away the result of your exploration in REPLs or notebooks. `nbdev` makes exploration an integral part of your workflow, all while promoting software engineering best practices." 133 | ] 134 | }, 135 | { 136 | "cell_type": "markdown", 137 | "metadata": {}, 138 | "source": [ 139 | "## Why not pyproject.toml for packaging?" 140 | ] 141 | }, 142 | { 143 | "cell_type": "markdown", 144 | "metadata": {}, 145 | "source": [ 146 | "FastHTML uses a `setup.py` module instead of a `pyproject.toml` file to configure itself for installation. The reason for this is `pyproject.toml` is not compatible with [nbdev](https://nbdev.fast.ai/), which is what is used to write and build FastHTML.\n", 147 | "\n", 148 | "The nbdev project spent around a year trying to move to pyproject.toml but there was insufficient functionality in the toml-based approach to complete the transition." 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": null, 154 | "metadata": {}, 155 | "outputs": [], 156 | "source": [] 157 | } 158 | ], 159 | "metadata": { 160 | "kernelspec": { 161 | "display_name": "python3", 162 | "language": "python", 163 | "name": "python3" 164 | } 165 | }, 166 | "nbformat": 4, 167 | "nbformat_minor": 2 168 | } 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastHTML 2 | 3 | 4 | 5 | 6 | Welcome to the official FastHTML documentation. 7 | 8 | FastHTML is a new next-generation web framework for fast, scalable web 9 | applications with minimal, compact code. It’s designed to be: 10 | 11 | - Powerful and expressive enough to build the most advanced, interactive 12 | web apps you can imagine. 13 | - Fast and lightweight, so you can write less code and get more done. 14 | - Easy to learn and use, with a simple, intuitive syntax that makes it 15 | easy to build complex apps quickly. 16 | 17 | FastHTML apps are just Python code, so you can use FastHTML with the 18 | full power of the Python language and ecosystem. FastHTML’s 19 | functionality maps 1:1 directly to HTML and HTTP, but allows them to be 20 | encapsulated using good software engineering practices—so you’ll need to 21 | understand these foundations to use this library fully. To understand 22 | how and why this works, please read this first: 23 | [fastht.ml/about](https://fastht.ml/about). 24 | 25 | ## Installation 26 | 27 | Since `fasthtml` is a Python library, you can install it with: 28 | 29 | ``` sh 30 | pip install python-fasthtml 31 | ``` 32 | 33 | In the near future, we hope to add component libraries that can likewise 34 | be installed via `pip`. 35 | 36 | ## Usage 37 | 38 | For a minimal app, create a file “main.py” as follows: 39 | 40 |
41 | 42 | **main.py** 43 | 44 | ``` python 45 | from fasthtml.common import * 46 | 47 | app,rt = fast_app() 48 | 49 | @rt('/') 50 | def get(): return Div(P('Hello World!'), hx_get="/change") 51 | 52 | serve() 53 | ``` 54 | 55 |
56 | 57 | Running the app with `python main.py` prints out a link to your running 58 | app: `http://localhost:5001`. Visit that link in your browser and you 59 | should see a page with the text “Hello World!”. Congratulations, you’ve 60 | just created your first FastHTML app! 61 | 62 | Adding interactivity is surprisingly easy, thanks to HTMX. Modify the 63 | file to add this function: 64 | 65 |
66 | 67 | **main.py** 68 | 69 | ``` python 70 | @rt('/change') 71 | def get(): return P('Nice to be here!') 72 | ``` 73 | 74 |
75 | 76 | You now have a page with a clickable element that changes the text when 77 | clicked. When clicking on this link, the server will respond with an 78 | “HTML partial”—that is, just a snippet of HTML which will be inserted 79 | into the existing page. In this case, the returned element will replace 80 | the original P element (since that’s the default behavior of HTMX) with 81 | the new version returned by the second route. 82 | 83 | This “hypermedia-based” approach to web development is a powerful way to 84 | build web applications. 85 | 86 | ### Getting help from AI 87 | 88 | Because FastHTML is newer than most LLMs, AI systems like Cursor, 89 | ChatGPT, Claude, and Copilot won’t give useful answers about it. To fix 90 | that problem, we’ve provided an LLM-friendly guide that teaches them how 91 | to use FastHTML. To use it, add this link for your AI helper to use: 92 | 93 | - [/llms-ctx.txt](https://www.fastht.ml/docs/llms-ctx.txt) 94 | 95 | This example is in a format based on recommendations from Anthropic for 96 | use with [Claude 97 | Projects](https://support.anthropic.com/en/articles/9517075-what-are-projects). 98 | This works so well that we’ve actually found that Claude can provide 99 | even better information than our own documentation! For instance, read 100 | through [this annotated Claude 101 | chat](https://gist.github.com/jph00/9559b0a563f6a370029bec1d1cc97b74) 102 | for some great getting-started information, entirely generated from a 103 | project using the above text file as context. 104 | 105 | If you use Cursor, type `@doc` then choose “*Add new doc*”, and use the 106 | /llms-ctx.txt link above. The context file is auto-generated from our 107 | [`llms.txt`](https://llmstxt.org/) (our proposed standard for providing 108 | AI-friendly information)—you can generate alternative versions suitable 109 | for other models as needed. 110 | 111 | ## Next Steps 112 | 113 | Start with the official sources to learn more about FastHTML: 114 | 115 | - [About](https://fastht.ml/about): Learn about the core ideas behind 116 | FastHTML 117 | - [Documentation](https://www.fastht.ml/docs): Learn from examples how 118 | to write FastHTML code 119 | - [Idiomatic 120 | app](https://github.com/AnswerDotAI/fasthtml/blob/main/examples/adv_app.py): 121 | Heavily commented source code walking through a complete application, 122 | including custom authentication, JS library connections, and database 123 | use. 124 | 125 | We also have a 1-hour intro video: 126 | 127 | 128 | 129 | The capabilities of FastHTML are vast and growing, and not all the 130 | features and patterns have been documented yet. Be prepared to invest 131 | time into studying and modifying source code, such as the main FastHTML 132 | repo’s notebooks and the official FastHTML examples repo: 133 | 134 | - [FastHTML Examples Repo on 135 | GitHub](https://github.com/AnswerDotAI/fasthtml-example) 136 | - [FastHTML Repo on GitHub](https://github.com/AnswerDotAI/fasthtml) 137 | 138 | Then explore the small but growing third-party ecosystem of FastHTML 139 | tutorials, notebooks, libraries, and components: 140 | 141 | - [FastHTML Gallery](https://gallery.fastht.ml): Learn from minimal 142 | examples of components (ie chat bubbles, click-to-edit, infinite 143 | scroll, etc) 144 | - [Creating Custom FastHTML Tags for Markdown 145 | Rendering](https://isaac-flath.github.io/website/posts/boots/FasthtmlTutorial.html) 146 | by Isaac Flath 147 | - [How to Build a Simple Login System in 148 | FastHTML](https://blog.mariusvach.com/posts/login-fasthtml) by Marius 149 | Vach 150 | - Your tutorial here! 151 | 152 | Finally, join the FastHTML community to ask questions, share your work, 153 | and learn from others: 154 | 155 | - [Discord](https://discord.gg/qcXvcxMhdP) 156 | 157 | ## Other languages and related projects 158 | 159 | If you’re not a Python user, or are keen to try out a new language, 160 | we’ll list here other projects that have a similar approach to FastHTML. 161 | (Please reach out if you know of any other projects that you’d like to 162 | see added.) 163 | 164 | - [htmgo](https://htmgo.dev/) (Go): “*htmgo is a lightweight pure go way 165 | to build interactive websites / web applications using go & htmx. By 166 | combining the speed & simplicity of go + hypermedia attributes (htmx) 167 | to add interactivity to websites, all conveniently wrapped in pure go, 168 | you can build simple, fast, interactive websites without touching 169 | javascript. All compiled to a single deployable binary*” 170 | 171 | If you’re just interested in functional HTML components, rather than a 172 | full HTMX server solution, consider: 173 | 174 | - [fastcore.xml.FT](https://fastcore.fast.ai/xml.html): This is actually 175 | what FastHTML uses behind the scenes 176 | - [htpy](https://htpy.dev/): Similar to 177 | [`fastcore.xml.FT`](https://fastcore.fast.ai/xml.html#ft), but with a 178 | somewhat different syntax 179 | - [elm-html](https://package.elm-lang.org/packages/elm/html/latest/): 180 | Elm’s built-in HTML library with a type-safe functional approach 181 | - [hiccup](https://github.com/weavejester/hiccup): Popular library for 182 | representing HTML in Clojure using vectors 183 | - [hiccl](https://github.com/garlic0x1/hiccl): HTML generation library 184 | for Common Lisp inspired by Clojure’s Hiccup 185 | - [Falco.Markup](https://github.com/pimbrouwers/Falco): F# HTML DSL and 186 | web framework with type-safe HTML generation 187 | - [Lucid](https://github.com/chrisdone/lucid): Type-safe HTML generation 188 | for Haskell using monad transformers 189 | - [dream-html](https://github.com/aantron/dream): Part of the Dream web 190 | framework for OCaml, provides type-safe HTML templating 191 | 192 | For other hypermedia application platforms, not based on HTMX, take a 193 | look at: 194 | 195 | - [Hotwire/Turbo](https://turbo.hotwired.dev/): Rails-oriented framework 196 | that similarly uses HTML-over-the-wire 197 | - [LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html): 198 | Phoenix framework’s solution for building interactive web apps with 199 | minimal JavaScript 200 | - [Unpoly](https://unpoly.com/): Another HTML-over-the-wire framework 201 | with progressive enhancement 202 | - [Livewire](https://laravel-livewire.com/): Laravel’s take on building 203 | dynamic interfaces with minimal JavaScript 204 | -------------------------------------------------------------------------------- /nbs/explains/websockets.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# WebSockets" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Websockets are a protocol for two-way, persistent communication between a client and server. This is different from HTTP, which uses a request/response model where the client sends a request and the server responds. With websockets, either party can send messages at any time, and the other party can respond.\n", 15 | "\n", 16 | "This allows for different applications to be built, including things like chat apps, live-updating dashboards, and real-time collaborative tools, which would require constant polling of the server for updates with HTTP.\n", 17 | "\n", 18 | "In FastHTML, you can create a websocket route using the `@app.ws` decorator. This decorator takes a route path, and optional `conn` and `disconn` parameters representing the `on_connect` and `on_disconnect` callbacks in websockets, respectively. The function decorated by `@app.ws` is the main function that is called when a message is received.\n", 19 | "\n", 20 | "Here's an example of a basic websocket route:\n", 21 | "```python\n", 22 | "@app.ws('/ws', conn=on_conn, disconn=on_disconn)\n", 23 | "async def on_message(msg:str, send):\n", 24 | " await send(Div('Hello ' + msg, id='notifications'))\n", 25 | " await send(Div('Goodbye ' + msg, id='notifications'))\n", 26 | "```\n", 27 | "\n", 28 | "The `on_message` function is the main function that is called when a message is received and can be named however you like. Similar to standard routes, the arguments to `on_message` are automatically parsed from the websocket payload for you, so you don't need to manually parse the message content. However, certain argument names are reserved for special purposes. Here are the most important ones:\n", 29 | "\n", 30 | "- `send` is a function that can be used to send text data to the client.\n", 31 | "- `data` is a dictionary containing the data sent by the client.\n", 32 | "- `ws` is a reference to the websocket object.\n", 33 | "\n", 34 | "For example, we can send a message to the client that just connected like this:\n", 35 | "```python\n", 36 | "async def on_conn(send):\n", 37 | " await send(Div('Hello, world!'))\n", 38 | "```\n", 39 | "\n", 40 | "Or if we receive a message from the client, we can send a message back to them:\n", 41 | "```python\n", 42 | "@app.ws('/ws', conn=on_conn, disconn=on_disconn)\n", 43 | "async def on_message(msg:str, send):\n", 44 | " await send(Div('You said: ' + msg, id='notifications'))\n", 45 | " # or...\n", 46 | " return Div('You said: ' + msg, id='notifications')\n", 47 | "```\n", 48 | "\n", 49 | "On the client side, we can use HTMX's websocket extension to open a websocket connection and send/receive messages. For example:\n", 50 | "```python\n", 51 | "from fasthtml.common import *\n", 52 | "\n", 53 | "app = FastHTML(exts='ws')\n", 54 | "\n", 55 | "@app.get('/')\n", 56 | "def home():\n", 57 | " cts = Div(\n", 58 | " Div(id='notifications'),\n", 59 | " Form(Input(id='msg'), id='form', ws_send=True),\n", 60 | " hx_ext='ws', ws_connect='/ws')\n", 61 | " return Titled('Websocket Test', cts)\n", 62 | "```\n", 63 | "\n", 64 | "This will create a websocket connection to the server on route `/ws`, and send any form submissions to the server via the websocket. The server will then respond by sending a message back to the client. The client will then update the message div with the message from the server using Out of Band Swaps, which means that the content is swapped with the same id without reloading the page.\n", 65 | "\n", 66 | "::: {.callout-note}\n", 67 | "Make sure you set `exts='ws'` when creating your `FastHTML` object if you want to use websockets so the extension is loaded.\n", 68 | ":::\n", 69 | "\n", 70 | "Putting it all together, the code for the client and server should look like this:\n", 71 | "\n", 72 | "```python\n", 73 | "from fasthtml.common import *\n", 74 | "\n", 75 | "app = FastHTML(exts='ws')\n", 76 | "rt = app.route\n", 77 | "\n", 78 | "@rt('/')\n", 79 | "def get():\n", 80 | " cts = Div(\n", 81 | " Div(id='notifications'),\n", 82 | " Form(Input(id='msg'), id='form', ws_send=True),\n", 83 | " hx_ext='ws', ws_connect='/ws')\n", 84 | " return Titled('Websocket Test', cts)\n", 85 | "\n", 86 | "@app.ws('/ws')\n", 87 | "async def ws(msg:str, send):\n", 88 | " await send(Div('Hello ' + msg, id='notifications'))\n", 89 | "\n", 90 | "serve()\n", 91 | "```\n", 92 | "\n", 93 | "This is a fairly simple example and could be done just as easily with standard HTTP requests, but it illustrates the basic idea of how websockets work. Let's look at a more complex example next." 94 | ] 95 | }, 96 | { 97 | "cell_type": "markdown", 98 | "metadata": {}, 99 | "source": [ 100 | "## Session data in Websockets\n", 101 | "\n", 102 | "Session data is shared between standard HTTP routes and Websockets. This means you can access, for example, logged in user ID inside websocket handler:\n", 103 | "\n", 104 | "```python\n", 105 | "from fasthtml.common import *\n", 106 | "\n", 107 | "app = FastHTML(exts='ws')\n", 108 | "rt = app.route\n", 109 | "\n", 110 | "@rt('/login')\n", 111 | "def get(session):\n", 112 | " session[\"person\"] = \"Bob\"\n", 113 | " return \"ok\"\n", 114 | "\n", 115 | "@app.ws('/ws')\n", 116 | "async def ws(msg:str, send, session):\n", 117 | " await send(Div(f'Hello {session.get(\"person\")}' + msg, id='notifications'))\n", 118 | "\n", 119 | "serve()\n", 120 | "```" 121 | ] 122 | }, 123 | { 124 | "cell_type": "markdown", 125 | "metadata": {}, 126 | "source": [ 127 | "## Real-Time Chat App\n", 128 | "\n", 129 | "Let's put our new websocket knowledge to use by building a simple chat app. We will create a chat app where multiple users can send and receive messages in real time.\n", 130 | "\n", 131 | "Let's start by defining the app and the home page:\n", 132 | "```python\n", 133 | "from fasthtml.common import *\n", 134 | "\n", 135 | "app = FastHTML(exts='ws')\n", 136 | "rt = app.route\n", 137 | "\n", 138 | "msgs = []\n", 139 | "@rt('/')\n", 140 | "def home(): return Div(\n", 141 | " Div(Ul(*[Li(m) for m in msgs], id='msg-list')),\n", 142 | " Form(Input(id='msg'), id='form', ws_send=True),\n", 143 | " hx_ext='ws', ws_connect='/ws')\n", 144 | "```\n", 145 | "\n", 146 | "Now, let's handle the websocket connection. We'll add a new route for this along with an `on_conn` and `on_disconn` function to keep track of the users currently connected to the websocket. Finally, we will handle the logic for sending messages to all connected users.\n", 147 | "\n", 148 | "```python\n", 149 | "users = {}\n", 150 | "def on_conn(ws, send): users[str(id(ws))] = send\n", 151 | "def on_disconn(ws): users.pop(str(id(ws)), None)\n", 152 | "\n", 153 | "@app.ws('/ws', conn=on_conn, disconn=on_disconn)\n", 154 | "async def ws(msg:str):\n", 155 | " msgs.append(msg)\n", 156 | " # Use associated `send` function to send message to each user\n", 157 | " for u in users.values(): await u(Ul(*[Li(m) for m in msgs], id='msg-list'))\n", 158 | "\n", 159 | "serve()\n", 160 | "```\n", 161 | "\n", 162 | "We can now run this app with `python chat_ws.py` and open multiple browser tabs to `http://localhost:5001`. You should be able to send messages in one tab and see them appear in the other tabs." 163 | ] 164 | }, 165 | { 166 | "cell_type": "markdown", 167 | "metadata": {}, 168 | "source": [ 169 | "### A Work in Progress" 170 | ] 171 | }, 172 | { 173 | "cell_type": "markdown", 174 | "metadata": {}, 175 | "source": [ 176 | "This page (and Websocket support in FastHTML) is a work in progress. Questions, PRs, and feedback are welcome!" 177 | ] 178 | }, 179 | { 180 | "cell_type": "code", 181 | "execution_count": null, 182 | "metadata": {}, 183 | "outputs": [], 184 | "source": [] 185 | } 186 | ], 187 | "metadata": { 188 | "kernelspec": { 189 | "display_name": "python3", 190 | "language": "python", 191 | "name": "python3" 192 | } 193 | }, 194 | "nbformat": 4, 195 | "nbformat_minor": 2 196 | } 197 | -------------------------------------------------------------------------------- /nbs/explains/stripe_otp.py: -------------------------------------------------------------------------------- 1 | # AUTOGENERATED! DO NOT EDIT! File to edit: Stripe.ipynb. 2 | 3 | # %% auto 0 4 | __all__ = ['DOMAIN_URL', 'app_nm', 'price_list', 'price', 'bware', 'app', 'rt', 'WEBHOOK_SECRET', 'db', 'payments', 5 | 'create_price', 'archive_price', 'before', 'home', 'create_checkout_session', 'Payment', 'post', 'success', 6 | 'cancel', 'refund', 'account_management'] 7 | 8 | # %% Stripe.ipynb 9 | from fasthtml.common import * 10 | import os 11 | 12 | # %% Stripe.ipynb 13 | import stripe 14 | 15 | # %% Stripe.ipynb 16 | stripe.api_key = os.environ.get("STRIPE_SECRET_KEY") 17 | DOMAIN_URL = os.environ.get("DOMAIN_URL", "http://localhost:5001") 18 | 19 | # %% Stripe.ipynb 20 | def _search_app(app_nm:str, limit=1): 21 | "Checks for product based on app_nm and returns the product if it exists" 22 | return stripe.Product.search(query=f"name:'{app_nm}' AND active:'True'", limit=limit).data 23 | 24 | def create_price(app_nm:str, amt:int, currency="usd") -> list[stripe.Price]: 25 | "Create a product and bind it to a price object. If product already exist just return the price list." 26 | existing_product = _search_app(app_nm) 27 | if existing_product: 28 | return stripe.Price.list(product=existing_product[0].id).data 29 | else: 30 | product = stripe.Product.create(name=f"{app_nm}") 31 | return [stripe.Price.create(product=product.id, unit_amount=amt, currency=currency)] 32 | 33 | def archive_price(app_nm:str): 34 | "Archive a price - useful for cleanup if testing." 35 | existing_products = _search_app(app_nm, limit=50) 36 | for product in existing_products: 37 | for price in stripe.Price.list(product=product.id).data: 38 | stripe.Price.modify(price.id, active=False) 39 | stripe.Product.modify(product.id, active=False) 40 | 41 | # %% Stripe.ipynb 42 | app_nm = "[FastHTML Docs] Demo Product" 43 | price_list = create_price(app_nm, amt=1999) 44 | assert len(price_list) == 1, 'For this tutorial, we only have one price bound to our product.' 45 | price = price_list[0] 46 | 47 | # %% Stripe.ipynb 48 | def before(sess): sess['auth'] = 'hamel@hamel.com' 49 | bware = Beforeware(before, skip=['/webhook']) 50 | app, rt = fast_app(before=bware) 51 | 52 | # %% Stripe.ipynb 53 | WEBHOOK_SECRET = os.getenv("STRIPE_LOCAL_TEST_WEBHOOK_SECRET") 54 | 55 | # %% Stripe.ipynb 56 | @rt("/") 57 | def home(sess): 58 | auth = sess['auth'] 59 | return Titled( 60 | "Buy Now", 61 | Div(H2("Demo Product - $19.99"), 62 | P(f"Welcome, {auth}"), 63 | Button("Buy Now", hx_post="/create-checkout-session", hx_swap="none"), 64 | A("View Account", href="/account"))) 65 | 66 | # %% Stripe.ipynb 67 | @rt("/create-checkout-session", methods=["POST"]) 68 | async def create_checkout_session(sess): 69 | checkout_session = stripe.checkout.Session.create( 70 | line_items=[{'price': price.id, 'quantity': 1}], 71 | mode='payment', 72 | payment_method_types=['card'], 73 | customer_email=sess['auth'], 74 | metadata={'app_name': app_nm, 75 | 'AnyOther': 'Metadata',}, 76 | # CHECKOUT_SESSION_ID is a special variable Stripe fills in for you 77 | success_url=DOMAIN_URL + '/success?checkout_sid={CHECKOUT_SESSION_ID}', 78 | cancel_url=DOMAIN_URL + '/cancel') 79 | return Redirect(checkout_session.url) 80 | 81 | # %% Stripe.ipynb 82 | # Database Table 83 | class Payment: 84 | checkout_session_id: str # Stripe checkout session ID (primary key) 85 | email: str 86 | amount: int # Amount paid in cents 87 | payment_status: str # paid, pending, failed 88 | created_at: int # Unix timestamp 89 | metadata: str # Additional payment metadata as JSON 90 | 91 | # %% Stripe.ipynb 92 | db = Database("stripe_payments.db") 93 | payments = db.create(Payment, pk='checkout_session_id', transform=True) 94 | 95 | # %% Stripe.ipynb 96 | @rt("/webhook") 97 | async def post(req): 98 | payload = await req.body() 99 | # Verify the event came from Stripe 100 | try: 101 | event = stripe.Webhook.construct_event( 102 | payload, req.headers.get("stripe-signature"), WEBHOOK_SECRET) 103 | except Exception as e: 104 | print(f"Webhook error: {e}") 105 | return 106 | if event and event.type == "checkout.session.completed": 107 | event_data = event.data.object 108 | if event_data.metadata.get('app_name') == app_nm: 109 | payment = Payment( 110 | checkout_session_id=event_data.id, 111 | email=event_data.customer_email, 112 | amount=event_data.amount_total, 113 | payment_status=event_data.payment_status, 114 | created_at=event_data.created, 115 | metadata=str(event_data.metadata)) 116 | payments.insert(payment) 117 | print(f"Payment recorded for user: {event_data.customer_email}") 118 | 119 | # Do not worry about refunds yet, we will cover how to do this later in the tutorial 120 | elif event and event.type == "charge.refunded": 121 | event_data = event.data.object 122 | payment_intent_id = event_data.payment_intent 123 | sessions = stripe.checkout.Session.list(payment_intent=payment_intent_id) 124 | if sessions and sessions.data: 125 | checkout_sid = sessions.data[0].id 126 | payments.update(Payment(checkout_session_id= checkout_sid, payment_status="refunded")) 127 | print(f"Refund recorded for payment: {checkout_sid}") 128 | 129 | # %% Stripe.ipynb 130 | @rt("/success") 131 | def success(sess, checkout_sid:str): 132 | # Get payment record from database (saved in the webhook) 133 | payment = payments[checkout_sid] 134 | 135 | if not payment or payment.payment_status != 'paid': 136 | return Titled("Error", P("Payment not found")) 137 | 138 | return Titled( 139 | "Success", 140 | Div(H2("Payment Successful!"), 141 | P(f"Thank you for your purchase, {sess['auth']}"), 142 | P(f"Amount Paid: ${payment.amount / 100:.2f}"), 143 | P(f"Status: {payment.payment_status}"), 144 | P(f"Transaction ID: {payment.checkout_session_id}"), 145 | A("Back to Home", href="/"))) 146 | 147 | # %% Stripe.ipynb 148 | @rt("/cancel") 149 | def cancel(): 150 | return Titled( 151 | "Cancelled", 152 | Div(H2("Payment Cancelled"), 153 | P("Your payment was cancelled."), 154 | A("Back to Home", href="/"))) 155 | 156 | # %% Stripe.ipynb 157 | @rt("/refund", methods=["POST"]) 158 | async def refund(sess, checkout_sid:str): 159 | # Get payment record from database 160 | payment = payments[checkout_sid] 161 | 162 | if not payment or payment.payment_status != 'paid': 163 | return P("Error: Payment not found or not eligible for refund") 164 | 165 | try: 166 | # Get the payment intent ID from the checkout session 167 | checkout_session = stripe.checkout.Session.retrieve(checkout_sid) 168 | 169 | # Process the refund 170 | refund = stripe.Refund.create(payment_intent=checkout_session.payment_intent, reason="requested_by_customer") 171 | 172 | # Update payment status in database 173 | payments.update(Payment(checkout_session_id= checkout_sid, payment_status="refunded")) 174 | 175 | return Div( 176 | P("Refund processed successfully!"), 177 | P(f"Refund ID: {refund.id}")) 178 | 179 | except Exception as e: return P(f"Refund failed: {str(e)}") 180 | 181 | # %% Stripe.ipynb 182 | @rt("/account") 183 | def account_management(sess): 184 | user_email = sess['auth'] 185 | user_payments = payments("email=?", (user_email,)) 186 | # Create table rows for each payment 187 | payment_rows = [] 188 | for payment in user_payments: 189 | action_button = "" 190 | if payment.payment_status == 'paid': 191 | action_button = Button("Request Refund", hx_post=f"/refund?checkout_sid={payment.checkout_session_id}",hx_target="#refund-status") 192 | elif payment.payment_status == 'refunded': action_button = "Refunded" 193 | 194 | # Add row to table 195 | payment_rows.append( 196 | Tr(*map(Td, (payment.created_at, payment.amount, payment.payment_status, action_button)))) 197 | 198 | # Create payment history table 199 | payment_history = Table( 200 | Thead(Tr(*map(Th, ("Date", "Amount", "Status", "Action")))), 201 | Tbody(*payment_rows)) 202 | 203 | return Titled( 204 | "Account Management", 205 | Div(H2(f"Account: {user_email}"), 206 | H3("Payment History"), 207 | payment_history, 208 | Div(id="refund-status"), # Target for refund status messages 209 | A("Back to Home", href="/"))) 210 | 211 | # %% Stripe.ipynb 212 | serve() 213 | -------------------------------------------------------------------------------- /fasthtml/svg.py: -------------------------------------------------------------------------------- 1 | """Simple SVG FT elements""" 2 | 3 | # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/05_svg.ipynb. 4 | 5 | # %% auto 0 6 | __all__ = ['g', 'svg_inb', 'Svg', 'ft_svg', 'Rect', 'Circle', 'Ellipse', 'transformd', 'Line', 'Polyline', 'Polygon', 'Text', 7 | 'PathFT', 'Path', 'SvgOob', 'SvgInb', 'AltGlyph', 'AltGlyphDef', 'AltGlyphItem', 'Animate', 'AnimateColor', 8 | 'AnimateMotion', 'AnimateTransform', 'ClipPath', 'Color_profile', 'Cursor', 'Defs', 'Desc', 'FeBlend', 9 | 'FeColorMatrix', 'FeComponentTransfer', 'FeComposite', 'FeConvolveMatrix', 'FeDiffuseLighting', 10 | 'FeDisplacementMap', 'FeDistantLight', 'FeFlood', 'FeFuncA', 'FeFuncB', 'FeFuncG', 'FeFuncR', 11 | 'FeGaussianBlur', 'FeImage', 'FeMerge', 'FeMergeNode', 'FeMorphology', 'FeOffset', 'FePointLight', 12 | 'FeSpecularLighting', 'FeSpotLight', 'FeTile', 'FeTurbulence', 'Filter', 'Font', 'Font_face', 13 | 'Font_face_format', 'Font_face_name', 'Font_face_src', 'Font_face_uri', 'ForeignObject', 'G', 'Glyph', 14 | 'GlyphRef', 'Hkern', 'Image', 'LinearGradient', 'Marker', 'Mask', 'Metadata', 'Missing_glyph', 'Mpath', 15 | 'Pattern', 'RadialGradient', 'Set', 'Stop', 'Switch', 'Symbol', 'TextPath', 'Tref', 'Tspan', 'Use', 'View', 16 | 'Vkern', 'Template'] 17 | 18 | # %% ../nbs/api/05_svg.ipynb 19 | from fastcore.utils import * 20 | from fastcore.meta import delegates 21 | from fastcore.xml import FT 22 | from .common import * 23 | from .components import * 24 | from .xtend import * 25 | 26 | # %% ../nbs/api/05_svg.ipynb 27 | _all_ = ['AltGlyph', 'AltGlyphDef', 'AltGlyphItem', 'Animate', 'AnimateColor', 'AnimateMotion', 'AnimateTransform', 'ClipPath', 'Color_profile', 'Cursor', 'Defs', 'Desc', 'FeBlend', 'FeColorMatrix', 'FeComponentTransfer', 'FeComposite', 'FeConvolveMatrix', 'FeDiffuseLighting', 'FeDisplacementMap', 'FeDistantLight', 'FeFlood', 'FeFuncA', 'FeFuncB', 'FeFuncG', 'FeFuncR', 'FeGaussianBlur', 'FeImage', 'FeMerge', 'FeMergeNode', 'FeMorphology', 'FeOffset', 'FePointLight', 'FeSpecularLighting', 'FeSpotLight', 'FeTile', 'FeTurbulence', 'Filter', 'Font', 'Font_face', 'Font_face_format', 'Font_face_name', 'Font_face_src', 'Font_face_uri', 'ForeignObject', 'G', 'Glyph', 'GlyphRef', 'Hkern', 'Image', 'LinearGradient', 'Marker', 'Mask', 'Metadata', 'Missing_glyph', 'Mpath', 'Pattern', 'RadialGradient', 'Set', 'Stop', 'Switch', 'Symbol', 'TextPath', 'Tref', 'Tspan', 'Use', 'View', 'Vkern', 'Template'] 28 | 29 | # %% ../nbs/api/05_svg.ipynb 30 | g = globals() 31 | for o in _all_: g[o] = partial(ft_hx, o[0].lower() + o[1:]) 32 | 33 | # %% ../nbs/api/05_svg.ipynb 34 | def Svg(*args, viewBox=None, h=None, w=None, height=None, width=None, xmlns="http://www.w3.org/2000/svg", **kwargs): 35 | "An SVG tag; xmlns is added automatically, and viewBox defaults to height and width if not provided" 36 | if h: height=h 37 | if w: width=w 38 | if not viewBox and height and width: viewBox=f'0 0 {width} {height}' 39 | return ft_svg('svg', *args, xmlns=xmlns, viewBox=viewBox, height=height, width=width, **kwargs) 40 | 41 | # %% ../nbs/api/05_svg.ipynb 42 | @delegates(ft_hx) 43 | def ft_svg(tag: str, *c, transform=None, opacity=None, clip=None, mask=None, filter=None, 44 | vector_effect=None, pointer_events=None, **kwargs): 45 | "Create a standard `FT` element with some SVG-specific attrs" 46 | return ft_hx(tag, *c, transform=transform, opacity=opacity, clip=clip, mask=mask, filter=filter, 47 | vector_effect=vector_effect, pointer_events=pointer_events, **kwargs) 48 | 49 | # %% ../nbs/api/05_svg.ipynb 50 | @delegates(ft_svg) 51 | def Rect(width, height, x=0, y=0, fill=None, stroke=None, stroke_width=None, rx=None, ry=None, **kwargs): 52 | "A standard SVG `rect` element" 53 | return ft_svg('rect', width=width, height=height, x=x, y=y, fill=fill, 54 | stroke=stroke, stroke_width=stroke_width, rx=rx, ry=ry, **kwargs) 55 | 56 | # %% ../nbs/api/05_svg.ipynb 57 | @delegates(ft_svg) 58 | def Circle(r, cx=0, cy=0, fill=None, stroke=None, stroke_width=None, **kwargs): 59 | "A standard SVG `circle` element" 60 | return ft_svg('circle', r=r, cx=cx, cy=cy, fill=fill, stroke=stroke, stroke_width=stroke_width, **kwargs) 61 | 62 | # %% ../nbs/api/05_svg.ipynb 63 | @delegates(ft_svg) 64 | def Ellipse(rx, ry, cx=0, cy=0, fill=None, stroke=None, stroke_width=None, **kwargs): 65 | "A standard SVG `ellipse` element" 66 | return ft_svg('ellipse', rx=rx, ry=ry, cx=cx, cy=cy, fill=fill, stroke=stroke, stroke_width=stroke_width, **kwargs) 67 | 68 | # %% ../nbs/api/05_svg.ipynb 69 | def transformd(translate=None, scale=None, rotate=None, skewX=None, skewY=None, matrix=None): 70 | "Create an SVG `transform` kwarg dict" 71 | funcs = [] 72 | if translate is not None: funcs.append(f"translate{translate}") 73 | if scale is not None: funcs.append(f"scale{scale}") 74 | if rotate is not None: funcs.append(f"rotate({','.join(map(str,rotate))})") 75 | if skewX is not None: funcs.append(f"skewX({skewX})") 76 | if skewY is not None: funcs.append(f"skewY({skewY})") 77 | if matrix is not None: funcs.append(f"matrix{matrix}") 78 | return dict(transform=' '.join(funcs)) if funcs else {} 79 | 80 | # %% ../nbs/api/05_svg.ipynb 81 | @delegates(ft_svg) 82 | def Line(x1, y1, x2=0, y2=0, stroke=None, w=None, stroke_width=None, **kwargs): 83 | "A standard SVG `line` element" 84 | if w: stroke_width = w 85 | return ft_svg('line', x1=x1, y1=y1, x2=x2, y2=y2, stroke=stroke, stroke_width=stroke_width, **kwargs) 86 | 87 | # %% ../nbs/api/05_svg.ipynb 88 | @delegates(ft_svg) 89 | def Polyline(*args, points=None, fill=None, stroke=None, stroke_width=None, **kwargs): 90 | "A standard SVG `polyline` element" 91 | if points is None: points = ' '.join(f"{x},{y}" for x, y in args) 92 | return ft_svg('polyline', points=points, fill=fill, stroke=stroke, stroke_width=stroke_width, **kwargs) 93 | 94 | # %% ../nbs/api/05_svg.ipynb 95 | @delegates(ft_svg) 96 | def Polygon(*args, points=None, fill=None, stroke=None, stroke_width=None, **kwargs): 97 | "A standard SVG `polygon` element" 98 | if points is None: points = ' '.join(f"{x},{y}" for x, y in args) 99 | return ft_svg('polygon', points=points, fill=fill, stroke=stroke, stroke_width=stroke_width, **kwargs) 100 | 101 | # %% ../nbs/api/05_svg.ipynb 102 | @delegates(ft_svg) 103 | def Text(*args, x=0, y=0, font_family=None, font_size=None, fill=None, text_anchor=None, 104 | dominant_baseline=None, font_weight=None, font_style=None, text_decoration=None, **kwargs): 105 | "A standard SVG `text` element" 106 | return ft_svg('text', *args, x=x, y=y, font_family=font_family, font_size=font_size, fill=fill, 107 | text_anchor=text_anchor, dominant_baseline=dominant_baseline, font_weight=font_weight, 108 | font_style=font_style, text_decoration=text_decoration, **kwargs) 109 | 110 | # %% ../nbs/api/05_svg.ipynb 111 | class PathFT(FT): 112 | def _append_cmd(self, cmd): 113 | if not isinstance(getattr(self, 'd'), str): self.d = cmd 114 | else: self.d += f' {cmd}' 115 | return self 116 | 117 | def M(self, x, y): 118 | "Move to." 119 | return self._append_cmd(f'M{x} {y}') 120 | 121 | def L(self, x, y): 122 | "Line to." 123 | return self._append_cmd(f'L{x} {y}') 124 | 125 | def H(self, x): 126 | "Horizontal line to." 127 | return self._append_cmd(f'H{x}') 128 | 129 | def V(self, y): 130 | "Vertical line to." 131 | return self._append_cmd(f'V{y}') 132 | 133 | def Z(self): 134 | "Close path." 135 | return self._append_cmd('Z') 136 | 137 | def C(self, x1, y1, x2, y2, x, y): 138 | "Cubic Bézier curve." 139 | return self._append_cmd(f'C{x1} {y1} {x2} {y2} {x} {y}') 140 | 141 | def S(self, x2, y2, x, y): 142 | "Smooth cubic Bézier curve." 143 | return self._append_cmd(f'S{x2} {y2} {x} {y}') 144 | 145 | def Q(self, x1, y1, x, y): 146 | "Quadratic Bézier curve." 147 | return self._append_cmd(f'Q{x1} {y1} {x} {y}') 148 | 149 | def T(self, x, y): 150 | "Smooth quadratic Bézier curve." 151 | return self._append_cmd(f'T{x} {y}') 152 | 153 | def A(self, rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y): 154 | "Elliptical Arc." 155 | return self._append_cmd(f'A{rx} {ry} {x_axis_rotation} {large_arc_flag} {sweep_flag} {x} {y}') 156 | 157 | # %% ../nbs/api/05_svg.ipynb 158 | @delegates(ft_svg) 159 | def Path(d='', fill=None, stroke=None, stroke_width=None, **kwargs): 160 | "Create a standard `path` SVG element. This is a special object" 161 | return ft_svg('path', d=d, fill=fill, stroke=stroke, stroke_width=stroke_width, ft_cls=PathFT, **kwargs) 162 | 163 | # %% ../nbs/api/05_svg.ipynb 164 | svg_inb = dict(hx_select="svg>*") 165 | 166 | # %% ../nbs/api/05_svg.ipynb 167 | def SvgOob(*args, **kwargs): 168 | "Wraps an SVG shape as required for an HTMX OOB swap" 169 | return Template(Svg(*args, **kwargs)) 170 | 171 | # %% ../nbs/api/05_svg.ipynb 172 | def SvgInb(*args, **kwargs): 173 | "Wraps an SVG shape as required for an HTMX inband swap" 174 | return Svg(*args, **kwargs), HtmxResponseHeaders(hx_reselect='svg>*') 175 | -------------------------------------------------------------------------------- /nbs/ref/response_types.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "2138aec8", 6 | "metadata": {}, 7 | "source": [ 8 | "# Response Types\n", 9 | "\n", 10 | "> A list of the different HTTP response types available to your FastHTML route handlers." 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "id": "88eaa1a8", 16 | "metadata": {}, 17 | "source": [ 18 | "FastHTML provides multiple HTTP response types that automatically set the appropriate HTTP content type and handle serialization. The main response types are:\n", 19 | "\n", 20 | "- FT components\n", 21 | "- Redirects (HTTP 303 and other 3xx codes)\n", 22 | "- JSON (for API endpoints)\n", 23 | "- Streams (EventStream, for Server-Side Events)\n", 24 | "- Plaintext" 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "id": "3b6bbbec", 30 | "metadata": {}, 31 | "source": [ 32 | "::: {.callout-tip}\n", 33 | "## What about websockets?\n", 34 | "\n", 35 | "Websockets have their own protocol and don't follow the HTTP request/response cycle. To learn more, check out our explanation about websockets [here](/explains/websockets.html).\n", 36 | ":::" 37 | ] 38 | }, 39 | { 40 | "cell_type": "markdown", 41 | "id": "5b1f5040", 42 | "metadata": {}, 43 | "source": [ 44 | "## Configuration" 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "id": "78cbdd09", 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "from fasthtml.common import *" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "id": "a42607c0", 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "app,rt = fast_app()" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "id": "c38a057e", 70 | "metadata": {}, 71 | "source": [ 72 | "`app` and `rt` are the common FastHTML route handler decorators. We instantiate them with the `fast_app` function." 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "id": "047ee3f1", 79 | "metadata": {}, 80 | "outputs": [], 81 | "source": [ 82 | "cli = Client(app)" 83 | ] 84 | }, 85 | { 86 | "cell_type": "markdown", 87 | "id": "d3d90266", 88 | "metadata": {}, 89 | "source": [ 90 | "FastHTML comes with the test client named `Client`. It allows us to test handlers via a simple interface where `.get()` is a `HTTP GET` request, `.post()` is a `HTTP POST` request." 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "id": "3b4499a6", 96 | "metadata": {}, 97 | "source": [ 98 | "## FT Component Response" 99 | ] 100 | }, 101 | { 102 | "cell_type": "code", 103 | "execution_count": null, 104 | "id": "8f609069", 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "@rt('/ft')\n", 109 | "def get(): return Html(Div('FT Component Response'))" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "id": "9e34a376", 115 | "metadata": {}, 116 | "source": [ 117 | "This is the response type you're probably most familiar with. Here the route handler returns an FT component, which FastHTML wraps in an HTML document with a head and body. " 118 | ] 119 | }, 120 | { 121 | "cell_type": "code", 122 | "execution_count": null, 123 | "id": "eb426503", 124 | "metadata": {}, 125 | "outputs": [ 126 | { 127 | "name": "stdout", 128 | "output_type": "stream", 129 | "text": [ 130 | " \n", 131 | " \n", 132 | "
FT Component Response
\n", 133 | " \n", 134 | "\n" 135 | ] 136 | } 137 | ], 138 | "source": [ 139 | "print(cli.get('/ft').text)" 140 | ] 141 | }, 142 | { 143 | "cell_type": "markdown", 144 | "id": "2508b86c", 145 | "metadata": {}, 146 | "source": [ 147 | "## Redirect Response" 148 | ] 149 | }, 150 | { 151 | "cell_type": "code", 152 | "execution_count": null, 153 | "id": "94a0dca3", 154 | "metadata": {}, 155 | "outputs": [], 156 | "source": [ 157 | "@rt('/rr')\n", 158 | "def get(): return Redirect('https://fastht.ml/')" 159 | ] 160 | }, 161 | { 162 | "cell_type": "markdown", 163 | "id": "e63437b1", 164 | "metadata": {}, 165 | "source": [ 166 | "Here in this route handler, `Redirect` redirects the user's browser to the new URL 'https://fastht.ml/' " 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": null, 172 | "id": "f4a60242", 173 | "metadata": {}, 174 | "outputs": [ 175 | { 176 | "name": "stdout", 177 | "output_type": "stream", 178 | "text": [ 179 | "http://testserver/rr\n", 180 | "303\n" 181 | ] 182 | } 183 | ], 184 | "source": [ 185 | "resp = cli.get('/rr')\n", 186 | "print(resp.url)\n", 187 | "print(resp.status_code)" 188 | ] 189 | }, 190 | { 191 | "cell_type": "markdown", 192 | "id": "264f5f02", 193 | "metadata": {}, 194 | "source": [ 195 | "You can see the URL in the response headers and `url` attribute, as well as a status code of 303. " 196 | ] 197 | }, 198 | { 199 | "cell_type": "markdown", 200 | "id": "accd0739", 201 | "metadata": {}, 202 | "source": [ 203 | "## JSON Response" 204 | ] 205 | }, 206 | { 207 | "cell_type": "code", 208 | "execution_count": null, 209 | "id": "d5e4b375", 210 | "metadata": {}, 211 | "outputs": [], 212 | "source": [ 213 | "@rt('/json')\n", 214 | "def get(): return {'hello': 'world'}" 215 | ] 216 | }, 217 | { 218 | "cell_type": "markdown", 219 | "id": "3fd9e7f7", 220 | "metadata": {}, 221 | "source": [ 222 | "This route handler returns a JSON response, where the `content-type` has been set to . " 223 | ] 224 | }, 225 | { 226 | "cell_type": "code", 227 | "execution_count": null, 228 | "id": "de0bc762", 229 | "metadata": {}, 230 | "outputs": [ 231 | { 232 | "name": "stdout", 233 | "output_type": "stream", 234 | "text": [ 235 | "Headers({'content-length': '17', 'content-type': 'application/json'})\n", 236 | "{'hello': 'world'}\n" 237 | ] 238 | } 239 | ], 240 | "source": [ 241 | "resp = cli.get('/json')\n", 242 | "print(resp.headers)\n", 243 | "print(resp.json())" 244 | ] 245 | }, 246 | { 247 | "cell_type": "markdown", 248 | "id": "43de3aff", 249 | "metadata": {}, 250 | "source": [ 251 | "You can see that the Content-Type header has been set to application/json, and that the response is simply the JSON without any HTML wrapping it." 252 | ] 253 | }, 254 | { 255 | "cell_type": "markdown", 256 | "id": "e6a383a1", 257 | "metadata": {}, 258 | "source": [ 259 | "## EventStream" 260 | ] 261 | }, 262 | { 263 | "cell_type": "code", 264 | "execution_count": null, 265 | "id": "29807196", 266 | "metadata": {}, 267 | "outputs": [], 268 | "source": [ 269 | "from time import sleep\n", 270 | "\n", 271 | "def counter():\n", 272 | " \"\"\"Counter is an generator that\n", 273 | " publishes a number every second.\n", 274 | " \"\"\"\n", 275 | " for i in range(3):\n", 276 | " yield sse_message(f\"Event {i}\")\n", 277 | " sleep(1)\n", 278 | "\n", 279 | "@rt('/stream')\n", 280 | "def get():\n", 281 | " return EventStream(counter())" 282 | ] 283 | }, 284 | { 285 | "cell_type": "markdown", 286 | "id": "ab837650", 287 | "metadata": {}, 288 | "source": [ 289 | "With server-sent events, it’s possible for a server to send new data to a web page at any time, by pushing messages to the web page. Unlike WebSockets, SSE can only go in one direction: server to client. SSE is also part of the HTTP specification unlike WebSockets which uses its own specification." 290 | ] 291 | }, 292 | { 293 | "cell_type": "code", 294 | "execution_count": null, 295 | "id": "c13f277b", 296 | "metadata": {}, 297 | "outputs": [ 298 | { 299 | "name": "stdout", 300 | "output_type": "stream", 301 | "text": [ 302 | "event: message\n", 303 | "data: Event 0\n", 304 | "\n", 305 | "event: message\n", 306 | "data: Event 1\n", 307 | "\n", 308 | "event: message\n", 309 | "data: Event 2\n", 310 | "\n", 311 | "\n" 312 | ] 313 | } 314 | ], 315 | "source": [ 316 | "resp = cli.get('/stream')\n", 317 | "print(resp.text)" 318 | ] 319 | }, 320 | { 321 | "cell_type": "markdown", 322 | "id": "fe89e6bd", 323 | "metadata": {}, 324 | "source": [ 325 | "Each one of the message events above arrived one second after the previous message event." 326 | ] 327 | }, 328 | { 329 | "cell_type": "markdown", 330 | "id": "c830a555", 331 | "metadata": {}, 332 | "source": [ 333 | "## Plaintext Response" 334 | ] 335 | }, 336 | { 337 | "cell_type": "code", 338 | "execution_count": null, 339 | "id": "7fa1ab1b", 340 | "metadata": {}, 341 | "outputs": [], 342 | "source": [ 343 | "@rt('/text')\n", 344 | "def get(): return 'Hello world'" 345 | ] 346 | }, 347 | { 348 | "cell_type": "markdown", 349 | "id": "fada95d6", 350 | "metadata": {}, 351 | "source": [ 352 | "When you return a string from a route handler, you get a plain-text response." 353 | ] 354 | }, 355 | { 356 | "cell_type": "code", 357 | "execution_count": null, 358 | "id": "dc1cf7ba", 359 | "metadata": {}, 360 | "outputs": [ 361 | { 362 | "name": "stdout", 363 | "output_type": "stream", 364 | "text": [ 365 | "Hello world\n" 366 | ] 367 | } 368 | ], 369 | "source": [ 370 | "print(cli.get('/text').text)" 371 | ] 372 | }, 373 | { 374 | "cell_type": "markdown", 375 | "id": "335cfd15", 376 | "metadata": {}, 377 | "source": [ 378 | "Here you can see that the response text is simply the string you returned, without any HTML wrapping it." 379 | ] 380 | } 381 | ], 382 | "metadata": { 383 | "kernelspec": { 384 | "display_name": "python3", 385 | "language": "python", 386 | "name": "python3" 387 | } 388 | }, 389 | "nbformat": 4, 390 | "nbformat_minor": 5 391 | } 392 | -------------------------------------------------------------------------------- /nbs/index.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# FastHTML\n", 8 | "\n", 9 | "> The fastest, most powerful way to create an HTML app" 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "Welcome to the official FastHTML documentation.\n", 17 | "\n", 18 | "FastHTML is a new next-generation web framework for fast, scalable web applications with minimal, compact code. It's designed to be:\n", 19 | "\n", 20 | "* Powerful and expressive enough to build the most advanced, interactive web apps you can imagine.\n", 21 | "* Fast and lightweight, so you can write less code and get more done.\n", 22 | "* Easy to learn and use, with a simple, intuitive syntax that makes it easy to build complex apps quickly.\n", 23 | "\n", 24 | "FastHTML apps are just Python code, so you can use FastHTML with the full power of the Python language and ecosystem. FastHTML's functionality maps 1:1 directly to HTML and HTTP, but allows them to be encapsulated using good software engineering practices---so you'll need to understand these foundations to use this library fully. To understand how and why this works, please read this first: [fastht.ml/about](https://fastht.ml/about)." 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "metadata": {}, 30 | "source": [ 31 | "## Installation\n", 32 | "\n", 33 | "Since `fasthtml` is a Python library, you can install it with:" 34 | ] 35 | }, 36 | { 37 | "cell_type": "markdown", 38 | "metadata": {}, 39 | "source": [ 40 | "```sh\n", 41 | "pip install python-fasthtml\n", 42 | "```" 43 | ] 44 | }, 45 | { 46 | "cell_type": "markdown", 47 | "metadata": {}, 48 | "source": [ 49 | "In the near future, we hope to add component libraries that can likewise be installed via `pip`." 50 | ] 51 | }, 52 | { 53 | "cell_type": "markdown", 54 | "metadata": {}, 55 | "source": [ 56 | "## Usage" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "metadata": {}, 62 | "source": [ 63 | "For a minimal app, create a file \"main.py\" as follows:\n", 64 | "\n", 65 | "```{.python filename=\"main.py\"}\n", 66 | "from fasthtml.common import *\n", 67 | "\n", 68 | "app,rt = fast_app()\n", 69 | "\n", 70 | "@rt('/')\n", 71 | "def get(): return Div(P('Hello World!'), hx_get=\"/change\")\n", 72 | "\n", 73 | "serve()\n", 74 | "```" 75 | ] 76 | }, 77 | { 78 | "cell_type": "markdown", 79 | "metadata": {}, 80 | "source": [ 81 | "Running the app with `python main.py` prints out a link to your running app: `http://localhost:5001`. Visit that link in your browser and you should see a page with the text \"Hello World!\". Congratulations, you've just created your first FastHTML app!" 82 | ] 83 | }, 84 | { 85 | "cell_type": "markdown", 86 | "metadata": {}, 87 | "source": [ 88 | "Adding interactivity is surprisingly easy, thanks to HTMX. Modify the file to add this function:\n", 89 | "\n", 90 | "```{.python filename=\"main.py\"}\n", 91 | "@rt('/change')\n", 92 | "def get(): return P('Nice to be here!')\n", 93 | "```\n", 94 | "\n", 95 | "You now have a page with a clickable element that changes the text when clicked. When clicking on this link, the server will respond with an \"HTML partial\"---that is, just a snippet of HTML which will be inserted into the existing page. In this case, the returned element will replace the original P element (since that's the default behavior of HTMX) with the new version returned by the second route.\n", 96 | "\n", 97 | "This \"hypermedia-based\" approach to web development is a powerful way to build web applications." 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "### Getting help from AI" 105 | ] 106 | }, 107 | { 108 | "cell_type": "markdown", 109 | "metadata": {}, 110 | "source": [ 111 | "Because FastHTML is newer than most LLMs, AI systems like Cursor, ChatGPT, Claude, and Copilot won't give useful answers about it. To fix that problem, we've provided an LLM-friendly guide that teaches them how to use FastHTML. To use it, add this link for your AI helper to use:\n", 112 | "\n", 113 | "- [/llms-ctx.txt](https://www.fastht.ml/docs/llms-ctx.txt)\n", 114 | "\n", 115 | "This example is in a format based on recommendations from Anthropic for use with [Claude Projects](https://support.anthropic.com/en/articles/9517075-what-are-projects). This works so well that we've actually found that Claude can provide even better information than our own documentation! For instance, read through [this annotated Claude chat](https://gist.github.com/jph00/9559b0a563f6a370029bec1d1cc97b74) for some great getting-started information, entirely generated from a project using the above text file as context.\n", 116 | "\n", 117 | "If you use Cursor, type `@doc` then choose \"*Add new doc*\", and use the /llms-ctx.txt link above. The context file is auto-generated from our [`llms.txt`](https://llmstxt.org/) (our proposed standard for providing AI-friendly information)---you can generate alternative versions suitable for other models as needed." 118 | ] 119 | }, 120 | { 121 | "cell_type": "markdown", 122 | "metadata": {}, 123 | "source": [ 124 | "## Next Steps" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "metadata": {}, 130 | "source": [ 131 | "Start with the official sources to learn more about FastHTML:\n", 132 | "\n", 133 | "- [About](https://fastht.ml/about): Learn about the core ideas behind FastHTML\n", 134 | "- [Documentation](https://www.fastht.ml/docs): Learn from examples how to write FastHTML code\n", 135 | "- [Idiomatic app](https://github.com/AnswerDotAI/fasthtml/blob/main/examples/adv_app.py): Heavily commented source code walking through a complete application, including custom authentication, JS library connections, and database use.\n", 136 | "\n", 137 | "We also have a 1-hour intro video:\n", 138 | "\n", 139 | "{{< video https://www.youtube.com/embed/Auqrm7WFc0I >}}\n", 140 | "\n", 141 | "The capabilities of FastHTML are vast and growing, and not all the features and patterns have been documented yet. Be prepared to invest time into studying and modifying source code, such as the main FastHTML repo's notebooks and the official FastHTML examples repo:\n", 142 | "\n", 143 | "- [FastHTML Examples Repo on GitHub](https://github.com/AnswerDotAI/fasthtml-example)\n", 144 | "- [FastHTML Repo on GitHub](https://github.com/AnswerDotAI/fasthtml)\n", 145 | "\n", 146 | "Then explore the small but growing third-party ecosystem of FastHTML tutorials, notebooks, libraries, and components:\n", 147 | "\n", 148 | "- [FastHTML Gallery](https://gallery.fastht.ml): Learn from minimal examples of components (ie chat bubbles, click-to-edit, infinite scroll, etc)\n", 149 | "- [Creating Custom FastHTML Tags for Markdown Rendering](https://isaac-flath.github.io/website/posts/boots/FasthtmlTutorial.html) by Isaac Flath\n", 150 | "- [How to Build a Simple Login System in FastHTML](https://blog.mariusvach.com/posts/login-fasthtml) by Marius Vach\n", 151 | "- Your tutorial here!\n", 152 | "\n", 153 | "Finally, join the FastHTML community to ask questions, share your work, and learn from others:\n", 154 | "\n", 155 | "- [Discord](https://discord.gg/qcXvcxMhdP)" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "metadata": {}, 161 | "source": [ 162 | "## Other languages and related projects" 163 | ] 164 | }, 165 | { 166 | "cell_type": "markdown", 167 | "metadata": {}, 168 | "source": [ 169 | "If you're not a Python user, or are keen to try out a new language, we'll list here other projects that have a similar approach to FastHTML. (Please reach out if you know of any other projects that you'd like to see added.)\n", 170 | "\n", 171 | "- [htmgo](https://htmgo.dev/) (Go): \"*htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx. By combining the speed & simplicity of go + hypermedia attributes (htmx) to add interactivity to websites, all conveniently wrapped in pure go, you can build simple, fast, interactive websites without touching javascript. All compiled to a single deployable binary*\"\n", 172 | "\n", 173 | "If you're just interested in functional HTML components, rather than a full HTMX server solution, consider:\n", 174 | "\n", 175 | "- [fastcore.xml.FT](https://fastcore.fast.ai/xml.html): This is actually what FastHTML uses behind the scenes\n", 176 | "- [htpy](https://htpy.dev/): Similar to `fastcore.xml.FT`, but with a somewhat different syntax\n", 177 | "- [elm-html](https://package.elm-lang.org/packages/elm/html/latest/): Elm's built-in HTML library with a type-safe functional approach\n", 178 | "- [hiccup](https://github.com/weavejester/hiccup): Popular library for representing HTML in Clojure using vectors\n", 179 | "- [hiccl](https://github.com/garlic0x1/hiccl): HTML generation library for Common Lisp inspired by Clojure's Hiccup\n", 180 | "- [Falco.Markup](https://github.com/pimbrouwers/Falco): F# HTML DSL and web framework with type-safe HTML generation\n", 181 | "- [Lucid](https://github.com/chrisdone/lucid): Type-safe HTML generation for Haskell using monad transformers\n", 182 | "- [dream-html](https://github.com/aantron/dream): Part of the Dream web framework for OCaml, provides type-safe HTML templating\n", 183 | "\n", 184 | "For other hypermedia application platforms, not based on HTMX, take a look at:\n", 185 | "\n", 186 | "- [Hotwire/Turbo](https://turbo.hotwired.dev/): Rails-oriented framework that similarly uses HTML-over-the-wire\n", 187 | "- [LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html): Phoenix framework's solution for building interactive web apps with minimal JavaScript\n", 188 | "- [Unpoly](https://unpoly.com/): Another HTML-over-the-wire framework with progressive enhancement\n", 189 | "- [Livewire](https://laravel-livewire.com/): Laravel's take on building dynamic interfaces with minimal JavaScript" 190 | ] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "metadata": {}, 196 | "outputs": [], 197 | "source": [] 198 | } 199 | ], 200 | "metadata": { 201 | "kernelspec": { 202 | "display_name": "python3", 203 | "language": "python", 204 | "name": "python3" 205 | } 206 | }, 207 | "nbformat": 4, 208 | "nbformat_minor": 4 209 | } 210 | -------------------------------------------------------------------------------- /nbs/explains/background_tasks.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Background Tasks\n", 8 | "\n", 9 | "> Background tasks are functions run after handlers return a response." 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "Useful for operations where the user gets a response quickly but doesn't need to wait for the operation to finish. Typical scenarios include:\n", 17 | "\n", 18 | "- User setup in complex systems where you can inform the user and other people later in email that their account is complete\n", 19 | "- Batch processes that can take a significant amount of time (bulk email or API calls)\n", 20 | "- Any other process where the user can be notified later by email, websocket, webhook, or pop-up" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "metadata": {}, 26 | "source": [ 27 | "::: {.callout-note}\n", 28 | "Background tasks in FastHTML are built on Starlette's background tasks, with added sugar. Starlette's background task design is an easy-to-use wrapper around Python's async and threading libraries. Background tasks make apps snappier to the end user and generally improve an app's speed.\n", 29 | ":::" 30 | ] 31 | }, 32 | { 33 | "cell_type": "markdown", 34 | "metadata": {}, 35 | "source": [ 36 | "## A simple background task example" 37 | ] 38 | }, 39 | { 40 | "cell_type": "markdown", 41 | "metadata": {}, 42 | "source": [ 43 | "In this example we are attaching a task to FtResponse by assigning it via the background argument. When the page is visited, it will display 'Simple Background Task Example' almost instantly, while in the terminal it will slowly count upward from 0." 44 | ] 45 | }, 46 | { 47 | "cell_type": "markdown", 48 | "metadata": {}, 49 | "source": [ 50 | "``` {.python filename=\"main.py\" code-line-numbers=\"true\"}\n", 51 | "from fasthtml.common import *\n", 52 | "from starlette.background import BackgroundTask\n", 53 | "from time import sleep\n", 54 | "\n", 55 | "app, rt = fast_app()\n", 56 | "\n", 57 | "def counter(loops:int): # <1>\n", 58 | " \"Slowly print integers to the terminal\"\n", 59 | " for i in range(loops):\n", 60 | " print(i)\n", 61 | " sleep(i)\n", 62 | "\n", 63 | "@rt\n", 64 | "def index():\n", 65 | " task = BackgroundTask(counter, loops=5) # <2>\n", 66 | " return Titled('Simple Background Task Example'), task # <3>\n", 67 | "\n", 68 | "serve()\n", 69 | "```\n", 70 | "\n", 71 | "1. `counter` is our task function. There is nothing special about it, although it is a good practice for its arguments to be serializable as JSON\n", 72 | "2. We use `starlette.background.BackgroundTask` to turn `counter()` into a background task\n", 73 | "3. To add a background task to a handler, we add it to the return values at the top level of the response." 74 | ] 75 | }, 76 | { 77 | "cell_type": "markdown", 78 | "metadata": {}, 79 | "source": [ 80 | "## A more realistic example" 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "Let's imagine that we are accessing a slow-to-process critical service. We don't want our users to have to wait. While we could set up SSE to notify on completion, instead we decide to periodically check to see if the status of their record has changed." 88 | ] 89 | }, 90 | { 91 | "cell_type": "markdown", 92 | "metadata": {}, 93 | "source": [ 94 | "### Simulated Slow API Service" 95 | ] 96 | }, 97 | { 98 | "cell_type": "markdown", 99 | "metadata": {}, 100 | "source": [ 101 | "First, create a very simple slow timestamp API. All it does is stall requests for a few seconds before returning JSON containing timestamps." 102 | ] 103 | }, 104 | { 105 | "cell_type": "markdown", 106 | "metadata": {}, 107 | "source": [ 108 | "```python\n", 109 | "# slow_api.py\n", 110 | "from fasthtml.common import *\n", 111 | "from time import sleep, time\n", 112 | "\n", 113 | "app, rt = fast_app()\n", 114 | "\n", 115 | "@rt('/slow')\n", 116 | "def slow(ts: int):\n", 117 | " sleep(3) # <1>\n", 118 | " return dict(request_time=ts, response_time=int(time())) # <2>\n", 119 | "\n", 120 | "serve(port=8123)\n", 121 | "```\n", 122 | "\n", 123 | "1. This represents slow processing.\n", 124 | "2. Returns both the task's original timestamp and the time after completion" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "metadata": {}, 130 | "source": [ 131 | "### Main FastHTML app" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": {}, 137 | "source": [ 138 | "Now let's create a user-facing app that uses this API to fetch the timestamp from the glacially slow service." 139 | ] 140 | }, 141 | { 142 | "cell_type": "markdown", 143 | "metadata": {}, 144 | "source": [ 145 | "```python\n", 146 | "# main.py\n", 147 | "from fasthtml.common import *\n", 148 | "from starlette.background import BackgroundTask\n", 149 | "import time\n", 150 | "import httpx\n", 151 | "\n", 152 | "app, rt = fast_app()\n", 153 | "\n", 154 | "db = database(':memory:')\n", 155 | "\n", 156 | "class TStamp: request_time: int; response_time: int # <1>\n", 157 | "\n", 158 | "tstamps = db.create(TStamp, pk='request_time')\n", 159 | "\n", 160 | "def task_submit(request_time: int): # <2>\n", 161 | " client = httpx.Client()\n", 162 | " response = client.post(f'http://127.0.0.1:8123/slow?ts={request_time}') # <3>\n", 163 | " tstamps.insert(**response.json()) # <4>\n", 164 | "\n", 165 | "@rt\n", 166 | "def submit():\n", 167 | " \"Route that initiates a background task and returns immediately.\"\n", 168 | " request_time = int(time.time())\n", 169 | " task = BackgroundTask(task_submit, request_time=request_time) # <5>\n", 170 | " return P(f'Request submitted at: {request_time}'), task # <6>\n", 171 | "\n", 172 | "@rt\n", 173 | "def show_tstamps(): return Ul(map(Li, tstamps())) # <7> \n", 174 | "\n", 175 | "@rt\n", 176 | "def index():\n", 177 | " return Titled('Background Task Dashboard',\n", 178 | " P(Button('Press to call slow service', # <8> \n", 179 | " hx_post=submit, hx_target='#res')),\n", 180 | " H2('Responses from Tasks'),\n", 181 | " P('', id='res'),\n", 182 | " Div(Ul(map(Li, tstamps())),\n", 183 | " hx_get=show_tstamps, hx_trigger='every 5s'), # <9>\n", 184 | " )\n", 185 | "\n", 186 | "serve()\n", 187 | "```\n", 188 | "\n", 189 | "1. Tracks when requests are sent and responses received\n", 190 | "2. Task function calling slow service to be run in the background of a route handler. It is common but not necessary to prefix task functions with 'task_'\n", 191 | "3. Call the slow API service (simulating a time-consuming operation)\n", 192 | "4. Store both timestamps in our database\n", 193 | "5. Create a background task by passing in the function to a BackgroundTask object, followed by any arguments.\n", 194 | "6. In FtResponse, use the background keyword argument to set the task to be run after the HTTP response is generated.\n", 195 | "7. Endpoint that displays all recorded timestamp pairs.\n", 196 | "8. When this button is pressed, the 'submit' handler will respond instantly. The task_submit function will insert the slow API response into the db later. \n", 197 | "9. Every 5 seconds get the tstamps stored in the DB." 198 | ] 199 | }, 200 | { 201 | "cell_type": "markdown", 202 | "metadata": {}, 203 | "source": [ 204 | "::: {.callout-tip}\n", 205 | "\n", 206 | "In the example above we use a synchronous background task function set in the `FtResponse` of a synchronous handler. However, we can also use asynchronous functions and handlers.\n", 207 | "\n", 208 | ":::" 209 | ] 210 | }, 211 | { 212 | "cell_type": "markdown", 213 | "metadata": {}, 214 | "source": [ 215 | "## Multiple background tasks in a handler" 216 | ] 217 | }, 218 | { 219 | "cell_type": "markdown", 220 | "metadata": {}, 221 | "source": [ 222 | "It is possible to add multiple background tasks to an FtResponse." 223 | ] 224 | }, 225 | { 226 | "cell_type": "markdown", 227 | "metadata": {}, 228 | "source": [ 229 | "::: {.callout-warning}\n", 230 | "Multiple background tasks on a background task are executed in order. In the case a task raises an exception, following tasks will not get the opportunity to be executed.\n", 231 | ":::" 232 | ] 233 | }, 234 | { 235 | "cell_type": "markdown", 236 | "metadata": {}, 237 | "source": [ 238 | "```python\n", 239 | "from starlette.background import BackgroundTasks\n", 240 | "\n", 241 | "@rt\n", 242 | "async def signup(email, username):\n", 243 | " tasks = BackgroundTasks()\n", 244 | " tasks.add_task(send_welcome_email, to_address=email)\n", 245 | " tasks.add_task(send_admin_notification, username=username)\n", 246 | " return Titled('Signup successful!'), tasks\n", 247 | "\n", 248 | "async def send_welcome_email(to_address):\n", 249 | " ...\n", 250 | "\n", 251 | "async def send_admin_notification(username):\n", 252 | " ...\n", 253 | "```" 254 | ] 255 | }, 256 | { 257 | "cell_type": "markdown", 258 | "metadata": {}, 259 | "source": [ 260 | "## Background tasks at scale\n", 261 | "\n", 262 | "Background tasks enhance application performance both for users and apps by handling blocking processes asynchronously, even when defined as synchronous functions.\n", 263 | "\n", 264 | "When FastHTML's background tasks aren't enough and your app runs slow on a server, manually offloading processes to the `multiprocessing` library is an option. By doing so you can leverage multiple cores and bypass the GIL, significantly improving speed and performance at the cost of added complexity.\n", 265 | "\n", 266 | "Sometimes a server reaches its processing limits, and this is where distributed task queue systems like Celery and Dramatiq come into play. They are designed to distribute tasks across multiple servers, offering improved observability, retry mechanisms, and persistence, at the cost of substantially increased complexity.\n", 267 | "\n", 268 | "However most applications work well with built-in background tasks like those in FastHTML, which we recommend trying first. Writing these functions with JSON-serializable arguments ensures straightforward conversion to other concurrency methods if needed." 269 | ] 270 | }, 271 | { 272 | "cell_type": "markdown", 273 | "metadata": {}, 274 | "source": [] 275 | } 276 | ], 277 | "metadata": { 278 | "kernelspec": { 279 | "display_name": "python3", 280 | "language": "python", 281 | "name": "python3" 282 | } 283 | }, 284 | "nbformat": 4, 285 | "nbformat_minor": 2 286 | } 287 | -------------------------------------------------------------------------------- /nbs/explains/routes.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Routes" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Behaviour in FastHTML apps is defined by routes. The syntax is largely the same as the wonderful [FastAPI](https://fastapi.tiangolo.com/) (which is what you should be using instead of this if you're creating a JSON service. FastHTML is mainly for making HTML web apps, not APIs).\n", 15 | "\n", 16 | ":::{.callout-warning title=\"Unfinished\"}\n", 17 | "We haven't yet written complete documentation of all of FastHTML's routing features -- until we add that, the best place to see all the available functionality is to look over [the tests](/api/core.html#tests)\n", 18 | ":::\n", 19 | "\n", 20 | "Note that you need to include the types of your parameters, so that `FastHTML` knows what to pass to your function. Here, we're just expecting a string:" 21 | ] 22 | }, 23 | { 24 | "cell_type": "code", 25 | "execution_count": null, 26 | "metadata": {}, 27 | "outputs": [], 28 | "source": [ 29 | "from fasthtml.common import *" 30 | ] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": null, 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "app = FastHTML()\n", 39 | "\n", 40 | "@app.get('/user/{nm}')\n", 41 | "def get_nm(nm:str): return f\"Good day to you, {nm}!\"" 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "metadata": {}, 47 | "source": [ 48 | "Normally you'd save this into a file such as main.py, and then run it in `uvicorn` using:\n", 49 | "\n", 50 | "```\n", 51 | "uvicorn main:app\n", 52 | "```\n", 53 | "\n", 54 | "However, for testing, we can use Starlette's `TestClient` to try it out:" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "from starlette.testclient import TestClient" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [ 71 | { 72 | "data": { 73 | "text/plain": [ 74 | "" 75 | ] 76 | }, 77 | "execution_count": null, 78 | "metadata": {}, 79 | "output_type": "execute_result" 80 | } 81 | ], 82 | "source": [ 83 | "client = TestClient(app)\n", 84 | "r = client.get('/user/Jeremy')\n", 85 | "r" 86 | ] 87 | }, 88 | { 89 | "cell_type": "markdown", 90 | "metadata": {}, 91 | "source": [ 92 | "TestClient uses `httpx` behind the scenes, so it returns a `httpx.Response`, which has a `text` attribute with our response body:" 93 | ] 94 | }, 95 | { 96 | "cell_type": "code", 97 | "execution_count": null, 98 | "metadata": {}, 99 | "outputs": [ 100 | { 101 | "data": { 102 | "text/plain": [ 103 | "'Good day to you, Jeremy!'" 104 | ] 105 | }, 106 | "execution_count": null, 107 | "metadata": {}, 108 | "output_type": "execute_result" 109 | } 110 | ], 111 | "source": [ 112 | "r.text" 113 | ] 114 | }, 115 | { 116 | "cell_type": "markdown", 117 | "metadata": {}, 118 | "source": [ 119 | "In the previous example, the function name (`get_nm`) didn't actually matter -- we could have just called it `_`, for instance, since we never actually call it directly. It's just called through HTTP. In fact, we often do call our functions `_` when using this style of route, since that's one less thing we have to worry about, naming.\n", 120 | "\n", 121 | "An alternative approach to creating a route is to use `app.route` instead, in which case, you make the function name the HTTP method you want. Since this is such a common pattern, you might like to give a shorter name to `app.route` -- we normally use `rt`:" 122 | ] 123 | }, 124 | { 125 | "cell_type": "code", 126 | "execution_count": null, 127 | "metadata": {}, 128 | "outputs": [ 129 | { 130 | "data": { 131 | "text/plain": [ 132 | "'Going postal!'" 133 | ] 134 | }, 135 | "execution_count": null, 136 | "metadata": {}, 137 | "output_type": "execute_result" 138 | } 139 | ], 140 | "source": [ 141 | "rt = app.route\n", 142 | "\n", 143 | "@rt('/')\n", 144 | "def post(): return \"Going postal!\"\n", 145 | "\n", 146 | "client.post('/').text" 147 | ] 148 | }, 149 | { 150 | "cell_type": "markdown", 151 | "metadata": {}, 152 | "source": [ 153 | "### Route-specific functionality" 154 | ] 155 | }, 156 | { 157 | "cell_type": "markdown", 158 | "metadata": {}, 159 | "source": [ 160 | "FastHTML supports custom decorators for adding specific functionality to routes. This allows you to implement authentication, authorization, middleware, or other custom behaviors for individual routes." 161 | ] 162 | }, 163 | { 164 | "cell_type": "markdown", 165 | "metadata": {}, 166 | "source": [ 167 | "Here's an example of a basic authentication decorator:" 168 | ] 169 | }, 170 | { 171 | "cell_type": "code", 172 | "execution_count": null, 173 | "metadata": {}, 174 | "outputs": [ 175 | { 176 | "data": { 177 | "text/plain": [ 178 | "'Protected Content'" 179 | ] 180 | }, 181 | "execution_count": null, 182 | "metadata": {}, 183 | "output_type": "execute_result" 184 | } 185 | ], 186 | "source": [ 187 | "from functools import wraps\n", 188 | "\n", 189 | "def basic_auth(f):\n", 190 | " @wraps(f)\n", 191 | " async def wrapper(req, *args, **kwargs):\n", 192 | " token = req.headers.get(\"Authorization\")\n", 193 | " if token == 'abc123':\n", 194 | " return await f(req, *args, **kwargs)\n", 195 | " return Response('Not Authorized', status_code=401)\n", 196 | " return wrapper\n", 197 | "\n", 198 | "@app.get(\"/protected\")\n", 199 | "@basic_auth\n", 200 | "async def protected(req):\n", 201 | " return \"Protected Content\"\n", 202 | "\n", 203 | "client.get('/protected', headers={'Authorization': 'abc123'}).text" 204 | ] 205 | }, 206 | { 207 | "cell_type": "markdown", 208 | "metadata": {}, 209 | "source": [ 210 | "The decorator intercepts the request before the route function executes. If the decorator allows the request to proceed, it calls the original route function, passing along the request and any other arguments." 211 | ] 212 | }, 213 | { 214 | "cell_type": "markdown", 215 | "metadata": {}, 216 | "source": [ 217 | "One of the key advantages of this approach is the ability to apply different behaviors to different routes. You can also stack multiple decorators on a single route for combined functionality." 218 | ] 219 | }, 220 | { 221 | "cell_type": "code", 222 | "execution_count": null, 223 | "metadata": {}, 224 | "outputs": [ 225 | { 226 | "name": "stdout", 227 | "output_type": "stream", 228 | "text": [ 229 | "App level beforeware\n", 230 | "Route level beforeware\n", 231 | "Second route level beforeware\n" 232 | ] 233 | }, 234 | { 235 | "data": { 236 | "text/plain": [ 237 | "'Users Page'" 238 | ] 239 | }, 240 | "execution_count": null, 241 | "metadata": {}, 242 | "output_type": "execute_result" 243 | } 244 | ], 245 | "source": [ 246 | "def app_beforeware():\n", 247 | " print('App level beforeware')\n", 248 | "\n", 249 | "app = FastHTML(before=Beforeware(app_beforeware))\n", 250 | "client = TestClient(app)\n", 251 | "\n", 252 | "def route_beforeware(f):\n", 253 | " @wraps(f)\n", 254 | " async def decorator(*args, **kwargs):\n", 255 | " print('Route level beforeware')\n", 256 | " return await f(*args, **kwargs)\n", 257 | " return decorator\n", 258 | " \n", 259 | "def second_route_beforeware(f):\n", 260 | " @wraps(f)\n", 261 | " async def decorator(*args, **kwargs):\n", 262 | " print('Second route level beforeware')\n", 263 | " return await f(*args, **kwargs)\n", 264 | " return decorator\n", 265 | "\n", 266 | "@app.get(\"/users\")\n", 267 | "@route_beforeware\n", 268 | "@second_route_beforeware\n", 269 | "async def users():\n", 270 | " return \"Users Page\"\n", 271 | "\n", 272 | "client.get('/users').text" 273 | ] 274 | }, 275 | { 276 | "cell_type": "markdown", 277 | "metadata": {}, 278 | "source": [ 279 | "This flexiblity allows for granular control over route behaviour, enabling you to tailor each endpoint's functionality as needed. While app-level beforeware remains useful for global operations, decorators provide a powerful tool for route-specific customization." 280 | ] 281 | }, 282 | { 283 | "cell_type": "markdown", 284 | "metadata": {}, 285 | "source": [ 286 | "## Combining Routes\n", 287 | "\n", 288 | "Sometimes a FastHTML project can grow so weildy that putting all the routes into `main.py` becomes unweildy. Or, we install a FastHTML- or Starlette-based package that requires us to add routes. \n", 289 | "\n", 290 | "First let's create a `books.py` module containing the views for displaying books:" 291 | ] 292 | }, 293 | { 294 | "cell_type": "code", 295 | "execution_count": null, 296 | "metadata": {}, 297 | "outputs": [], 298 | "source": [ 299 | "# books.py\n", 300 | "books_app, rt = fast_app()\n", 301 | "\n", 302 | "books = ['A Guide to FastHTML', 'FastHTML Cookbook', 'FastHTML in 24 Hours']\n", 303 | "\n", 304 | "@rt(\"/\", name=\"list\")\n", 305 | "def get():\n", 306 | " return Titled(\"Books\", *[P(book) for book in books])" 307 | ] 308 | }, 309 | { 310 | "cell_type": "markdown", 311 | "metadata": {}, 312 | "source": [ 313 | "Let's mount it in our main module:" 314 | ] 315 | }, 316 | { 317 | "cell_type": "markdown", 318 | "metadata": {}, 319 | "source": [ 320 | "```python\n", 321 | "from books import books_app\n", 322 | "\n", 323 | "app, rt = fast_app(routes=[Mount(\"/books\", books_app, name=\"books\")]) # <1>\n", 324 | "\n", 325 | "@rt(\"/\")\n", 326 | "def get():\n", 327 | " return Titled(\"Dashboard\",\n", 328 | " P(A(href=\"/books\")(\"Books\")), # <2>\n", 329 | " Hr(),\n", 330 | " P(A(link=uri(\"books:list\"))(\"Books\")), # <3>\n", 331 | " )\n", 332 | "\n", 333 | "serve()\n", 334 | "```\n", 335 | "\n", 336 | "1. We use `starlette.Mount` to add the route to our routes list. We provide the name of `books` to make discovery and management of the links easier. More on that in items 2 and 3 of this annotations list\n", 337 | "2. This example link to the books list view is hand-crafted. Obvious in purpose, it makes changing link patterns in the future harder\n", 338 | "3. This example link uses the named URL route for the books. The advantage of this approach is it makes management of large numbers of link items easier.\n" 339 | ] 340 | } 341 | ], 342 | "metadata": { 343 | "kernelspec": { 344 | "display_name": "python3", 345 | "language": "python", 346 | "name": "python3" 347 | } 348 | }, 349 | "nbformat": 4, 350 | "nbformat_minor": 4 351 | } 352 | --------------------------------------------------------------------------------