python-fasthtml 0.14.11__tar.gz → 0.14.13__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/CONTRIBUTING.md +1 -1
- {python_fasthtml-0.14.11/python_fasthtml.egg-info → python_fasthtml-0.14.13}/PKG-INFO +2 -3
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/README.md +0 -1
- python_fasthtml-0.14.13/fasthtml/__init__.py +8 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/_modidx.py +1 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/components.py +23 -14
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/core.py +137 -52
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/fastapp.py +2 -2
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/jupyter.py +10 -6
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/pico.py +7 -7
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/xtend.py +16 -14
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/pyproject.toml +1 -2
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13/python_fasthtml.egg-info}/PKG-INFO +2 -3
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/requires.txt +1 -1
- python_fasthtml-0.14.11/fasthtml/__init__.py +0 -2
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/LICENSE +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/MANIFEST.in +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/authmw.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/basics.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/cli.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/common.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/components.pyi +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/core.pyi +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/ft.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/js.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/katex.js +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/live_reload.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/oauth.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/starlette.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/stripe_otp.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/svg.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/toaster.py +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/xtend.pyi +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/SOURCES.txt +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/dependency_links.txt +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/entry_points.txt +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/top_level.txt +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/setup.cfg +0 -0
- {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/tests/test_toaster.py +0 -0
|
@@ -8,7 +8,7 @@ This project uses [nbdev](https://nbdev.fast.ai/getting_started.html) for develo
|
|
|
8
8
|
You may want to set up a `prep` alias in `~/.zshrc` or other shell startup file:
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
alias prep='
|
|
11
|
+
alias prep='nbdev-export && nbdev-clean && nbdev-trust'
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Run `prep` before each commit to ensure your python files are up to date, and you notebooks cleaned of metadata and notarized.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-fasthtml
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.13
|
|
4
4
|
Summary: The fastest way to create an HTML app
|
|
5
5
|
Author-email: Jeremy Howard and contributors <github@jhoward.fastmail.fm>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: fastcore>=2.
|
|
18
|
+
Requires-Dist: fastcore>=2.2.7
|
|
19
19
|
Requires-Dist: python-dateutil
|
|
20
20
|
Requires-Dist: starlette>=1.0.1
|
|
21
21
|
Requires-Dist: oauthlib
|
|
@@ -128,7 +128,6 @@ The capabilities of FastHTML are vast and growing, and not all the features and
|
|
|
128
128
|
Then explore the small but growing third-party ecosystem of FastHTML tutorials, notebooks, libraries, and components:
|
|
129
129
|
|
|
130
130
|
- [FastHTML Gallery](https://gallery.fastht.ml): Learn from minimal examples of components (ie chat bubbles, click-to-edit, infinite scroll, etc)
|
|
131
|
-
- [Creating Custom FastHTML Tags for Markdown Rendering](https://isaac-flath.github.io/website/posts/boots/FasthtmlTutorial.html) by Isaac Flath
|
|
132
131
|
- [How to Build a Simple Login System in FastHTML](https://blog.mariusvach.com/posts/login-fasthtml) by Marius Vach
|
|
133
132
|
- Your tutorial here!
|
|
134
133
|
|
|
@@ -93,7 +93,6 @@ The capabilities of FastHTML are vast and growing, and not all the features and
|
|
|
93
93
|
Then explore the small but growing third-party ecosystem of FastHTML tutorials, notebooks, libraries, and components:
|
|
94
94
|
|
|
95
95
|
- [FastHTML Gallery](https://gallery.fastht.ml): Learn from minimal examples of components (ie chat bubbles, click-to-edit, infinite scroll, etc)
|
|
96
|
-
- [Creating Custom FastHTML Tags for Markdown Rendering](https://isaac-flath.github.io/website/posts/boots/FasthtmlTutorial.html) by Isaac Flath
|
|
97
96
|
- [How to Build a Simple Login System in FastHTML](https://blog.mariusvach.com/posts/login-fasthtml) by Marius Vach
|
|
98
97
|
- Your tutorial here!
|
|
99
98
|
|
|
@@ -130,6 +130,7 @@ d = { 'settings': { 'branch': 'main',
|
|
|
130
130
|
'fasthtml.core._resp': ('api/core.html#_resp', 'fasthtml/core.py'),
|
|
131
131
|
'fasthtml.core._route_pn': ('api/core.html#_route_pn', 'fasthtml/core.py'),
|
|
132
132
|
'fasthtml.core._send_ws': ('api/core.html#_send_ws', 'fasthtml/core.py'),
|
|
133
|
+
'fasthtml.core._spawn': ('api/core.html#_spawn', 'fasthtml/core.py'),
|
|
133
134
|
'fasthtml.core._to_htmx_header': ('api/core.html#_to_htmx_header', 'fasthtml/core.py'),
|
|
134
135
|
'fasthtml.core._to_xml': ('api/core.html#_to_xml', 'fasthtml/core.py'),
|
|
135
136
|
'fasthtml.core._url_for': ('api/core.html#_url_for', 'fasthtml/core.py'),
|
|
@@ -5,18 +5,18 @@ Docs: https://www.fastht.ml/docs/api/components.html.md"""
|
|
|
5
5
|
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/01_components.ipynb.
|
|
6
6
|
|
|
7
7
|
# %% auto #0
|
|
8
|
-
__all__ = ['named', 'html_attrs', 'hx_attrs', '
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'Var', 'Video', 'Wbr']
|
|
8
|
+
__all__ = ['named', 'html_attrs', 'hx_attrs', 'hx_attrs_4only', 'hx_evts', 'hx_evts_4only', 'js_evts', 'hx_attrs_annotations',
|
|
9
|
+
'hx_evt_attrs', 'js_evt_attrs', 'evt_attrs', 'HxPartial', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show',
|
|
10
|
+
'fill_form', 'fill_dataclass', 'find_inputs', 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area',
|
|
11
|
+
'Article', 'Aside', 'Audio', 'B', 'Base', 'Bdi', 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas',
|
|
12
|
+
'Caption', 'Cite', 'Code', 'Col', 'Colgroup', 'Data', 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog',
|
|
13
|
+
'Div', 'Dl', 'Dt', 'Em', 'Embed', 'Fencedframe', 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1',
|
|
14
|
+
'H2', 'H3', 'H4', 'H5', 'H6', 'Head', 'Header', 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd',
|
|
15
|
+
'Label', 'Legend', 'Li', 'Link', 'Main', 'Map', 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object',
|
|
16
|
+
'Ol', 'Optgroup', 'Option', 'Output', 'P', 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp',
|
|
17
|
+
'Rt', 'Ruby', 'S', 'Samp', 'Script', 'Search', 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span',
|
|
18
|
+
'Strong', 'Style', 'Sub', 'Summary', 'Sup', 'Table', 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th',
|
|
19
|
+
'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr']
|
|
20
20
|
|
|
21
21
|
# %% ../nbs/api/01_components.ipynb #8e2d405b
|
|
22
22
|
from dataclasses import dataclass, asdict, is_dataclass, make_dataclass, replace, astuple, MISSING
|
|
@@ -46,10 +46,13 @@ def __add__(self:FT, b): return f'{self}{b}'
|
|
|
46
46
|
named = set('a button form frame iframe img input map meta object param select textarea'.split())
|
|
47
47
|
html_attrs = 'id cls title style accesskey contenteditable dir draggable enterkeyhint hidden inert inputmode lang popover spellcheck tabindex translate'.split()
|
|
48
48
|
hx_attrs = 'get post put delete patch trigger target swap swap_oob include select select_oob indicator push_url confirm disable replace_url vals disabled_elt ext headers history history_elt inherit params preserve prompt request sync validate'
|
|
49
|
+
hx_attrs_4only = 'action method config ignore' # https://four.htmx.org/docs/get-started/migration#attributes
|
|
49
50
|
|
|
50
51
|
hx_evts = 'abort afterOnLoad afterProcessNode afterRequest afterSettle afterSwap beforeCleanupElement beforeOnLoad beforeProcessNode beforeRequest beforeSwap beforeSend beforeTransition configRequest confirm historyCacheError historyCacheMiss historyCacheMissError historyCacheMissLoad historyRestore beforeHistorySave load noSSESourceError onLoadError oobAfterSwap oobBeforeSwap oobErrorNoTarget prompt pushedIntoHistory replacedInHistory responseError sendAbort sendError sseError sseOpen swapError targetError timeout validation:validate validation:failed validation:halted xhr:abort xhr:loadend xhr:loadstart xhr:progress'
|
|
52
|
+
# https://four.htmx.org/docs/get-started/migration#renamed-events
|
|
53
|
+
hx_evts_4only = 'after:init before:cleanup before:history:update before:init before:process before:history:restore after:history:push after:history:replace error'
|
|
51
54
|
js_evts = "blur change contextmenu focus input invalid reset select submit keydown keypress keyup click dblclick mousedown mouseenter mouseleave mousemove mouseout mouseover mouseup wheel"
|
|
52
|
-
hx_attrs = [f'hx_{o}' for o in hx_attrs.split()]
|
|
55
|
+
hx_attrs = [f'hx_{o}' for o in hx_attrs.split() + hx_attrs_4only.split()]
|
|
53
56
|
hx_attrs_annotations = {
|
|
54
57
|
"hx_swap": Literal["innerHTML", "outerHTML", "afterbegin", "beforebegin", "beforeend", "afterend", "delete", "none"] | str,
|
|
55
58
|
"hx_swap_oob": Literal["true", "innerHTML", "outerHTML", "afterbegin", "beforebegin", "beforeend", "afterend", "delete", "none"] | str,
|
|
@@ -64,7 +67,7 @@ hx_attrs_annotations |= {o: str for o in set(hx_attrs) - set(hx_attrs_annotation
|
|
|
64
67
|
hx_attrs_annotations = {k: Optional[v] for k,v in hx_attrs_annotations.items()}
|
|
65
68
|
hx_attrs = html_attrs + hx_attrs
|
|
66
69
|
|
|
67
|
-
hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split()]
|
|
70
|
+
hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split() + hx_evts_4only.split()]
|
|
68
71
|
js_evt_attrs = ['hx_on_'+o for o in js_evts.split()]
|
|
69
72
|
evt_attrs = js_evt_attrs+hx_evt_attrs
|
|
70
73
|
|
|
@@ -117,6 +120,10 @@ _all_ = [
|
|
|
117
120
|
'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr']
|
|
118
121
|
for o in _all_: _g[o] = partial(ft_hx, o.lower())
|
|
119
122
|
|
|
123
|
+
# %% ../nbs/api/01_components.ipynb #dfa8f01c
|
|
124
|
+
HxPartial = partial(ft_hx, 'hx-partial') # For hx v4, manually mapping because Python doesn't allow hyphens
|
|
125
|
+
|
|
126
|
+
|
|
120
127
|
# %% ../nbs/api/01_components.ipynb #fab04fb3
|
|
121
128
|
def File(fname):
|
|
122
129
|
"Use the unescaped text in file `fname` directly"
|
|
@@ -243,4 +250,6 @@ def html2ft(html, attr1st=False):
|
|
|
243
250
|
def sse_message(elm, event='message'):
|
|
244
251
|
"Convert element `elm` into a format suitable for SSE streaming"
|
|
245
252
|
data = '\n'.join(f'data: {o}' for o in to_xml(elm).splitlines())
|
|
253
|
+
# SSE delivers a message with no event field as type 'message', so the line is redundant for the default (and htmx v4 requires its absence for plain swaps)
|
|
254
|
+
if event=='message': return f'{data}\n\n'
|
|
246
255
|
return f'event: {event}\n{data}\n\n'
|
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
"""The `FastHTML` subclass of `Starlette`.
|
|
2
2
|
|
|
3
|
+
Create an app with `app = FastHTML()`. The `@rt` decorator adds routes (`rt = app.route`), inferring what it can from the function:
|
|
4
|
+
|
|
5
|
+
@rt
|
|
6
|
+
def index(): ... # GET,POST /
|
|
7
|
+
@rt
|
|
8
|
+
def foo(): ... # GET,POST /foo
|
|
9
|
+
@rt('/hi')
|
|
10
|
+
def get(): ... # GET /hi (function named after a verb handles just that verb)
|
|
11
|
+
@rt('/cards/{id}')
|
|
12
|
+
class Cards: ... # class route group: each verb-named method (get, patch, ...) becomes a handler
|
|
13
|
+
|
|
14
|
+
The decorated function doubles as a URL builder, so links never need hardcoded paths:
|
|
15
|
+
|
|
16
|
+
A('More', href=foo.to(a=1)) # <a href="/foo?a=1"> `.to()` fills path params; the rest become query params.
|
|
17
|
+
Div('...', hx_get=foo) # route functions work directly as htmx attr values: hx-get="/foo"
|
|
18
|
+
|
|
19
|
+
Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in `<head>`; the rest goes in `<body>`. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.
|
|
20
|
+
|
|
21
|
+
Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:
|
|
22
|
+
|
|
23
|
+
req, ws the Request / WebSocket connection
|
|
24
|
+
sess session dict
|
|
25
|
+
app the FastHTML app
|
|
26
|
+
state app.state
|
|
27
|
+
scope ASGI scope
|
|
28
|
+
auth scope['auth']
|
|
29
|
+
htmx HtmxHeaders (parsed HX-* request headers)
|
|
30
|
+
body raw body text
|
|
31
|
+
data parsed form data as a dict
|
|
32
|
+
api ApiReturn (API vs browser dual responses)
|
|
33
|
+
send websocket send function
|
|
34
|
+
hdrs,ftrs the per-request copies of the app's `hdrs`/`ftrs` (head and footer content)
|
|
35
|
+
htmlkw the per-request copy of the app's `<html>` attrs
|
|
36
|
+
bodykw the per-request copy of the app's `<body>` attrs
|
|
37
|
+
resp the handler's response (injected into `after` functions)
|
|
38
|
+
|
|
39
|
+
`before`/`after` functions (app-level, with `Beforeware` skip patterns, or per-route `before=`) get the same param injection as handlers. A `before` returning a value responds with it and skips the handler (the usual auth-guard pattern).
|
|
40
|
+
|
|
41
|
+
See the [Handling Handlers tutorial](https://www.fastht.ml/docs/ref/handlers.html.md) for worked examples of the above.
|
|
42
|
+
|
|
3
43
|
Docs: https://www.fastht.ml/docs/api/core.html.md"""
|
|
4
44
|
|
|
5
45
|
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/00_core.ipynb.
|
|
6
46
|
|
|
7
47
|
# %% auto #0
|
|
8
|
-
__all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', '
|
|
9
|
-
'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date',
|
|
48
|
+
__all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc',
|
|
49
|
+
'scopesrc', 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date',
|
|
10
50
|
'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn',
|
|
11
51
|
'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple',
|
|
12
52
|
'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML',
|
|
@@ -15,7 +55,7 @@ __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_ex
|
|
|
15
55
|
'MiddlewareBase', 'FtResponse', 'unqid']
|
|
16
56
|
|
|
17
57
|
# %% ../nbs/api/00_core.ipynb #23503b9e
|
|
18
|
-
import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib
|
|
58
|
+
import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous,hashlib,signal,subprocess
|
|
19
59
|
from uuid import uuid5, NAMESPACE_URL
|
|
20
60
|
|
|
21
61
|
from fastcore.utils import *
|
|
@@ -64,14 +104,18 @@ htmx_hdrs = dict(
|
|
|
64
104
|
history_restore_request="HX-History-Restore-Request",
|
|
65
105
|
prompt="HX-Prompt",
|
|
66
106
|
request="HX-Request",
|
|
107
|
+
request_type="HX-Request-Type", # v4: partial or full update
|
|
108
|
+
source="HX-Source", # v4: replaces v2 HX-Trigger but with tag#id instead just id
|
|
67
109
|
target="HX-Target",
|
|
68
110
|
trigger_name="HX-Trigger-Name",
|
|
69
111
|
trigger="HX-Trigger")
|
|
70
112
|
|
|
71
113
|
@dataclass
|
|
72
114
|
class HtmxHeaders:
|
|
115
|
+
"Parsed HX-* request headers"
|
|
73
116
|
boosted:str|None=None; current_url:str|None=None; history_restore_request:str|None=None; prompt:str|None=None
|
|
74
|
-
request:str|None=None;
|
|
117
|
+
request:str|None=None; request_type:str|None=None; source:str|None=None; target:str|None=None;
|
|
118
|
+
trigger_name:str|None=None; trigger:str|None=None
|
|
75
119
|
def __bool__(self): return any(hasattr(self,o) for o in htmx_hdrs)
|
|
76
120
|
|
|
77
121
|
def _get_htmx(h):
|
|
@@ -117,13 +161,15 @@ def _form_arg(k, v, d):
|
|
|
117
161
|
|
|
118
162
|
# %% ../nbs/api/00_core.ipynb #5fc04751
|
|
119
163
|
@dataclass
|
|
120
|
-
class HttpHeader:
|
|
164
|
+
class HttpHeader:
|
|
165
|
+
"A response header as a k,v pair"
|
|
166
|
+
k:str;v:str
|
|
121
167
|
|
|
122
168
|
# %% ../nbs/api/00_core.ipynb #94e18161
|
|
123
169
|
def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()
|
|
124
170
|
|
|
125
171
|
htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,
|
|
126
|
-
|
|
172
|
+
reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)
|
|
127
173
|
|
|
128
174
|
# %% ../nbs/api/00_core.ipynb #f6a2e62e
|
|
129
175
|
@use_kwargs_dict(**htmx_resps)
|
|
@@ -188,6 +234,7 @@ async def _from_body(conn, p, data):
|
|
|
188
234
|
|
|
189
235
|
# %% ../nbs/api/00_core.ipynb #88b6da3f
|
|
190
236
|
class ApiReturn:
|
|
237
|
+
"Request param: call with browser and API responses; returns kwargs as JSON if the client accepts `application/json`, else `norm`"
|
|
191
238
|
@classmethod
|
|
192
239
|
async def __from_request__(cls, data, req): return cls(req.headers.get('accept')=='application/json')
|
|
193
240
|
def __init__(self, isapi=False): self.isapi = isapi
|
|
@@ -242,6 +289,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter):
|
|
|
242
289
|
if res in (empty,None): res = conn.query_params.getlist(arg)
|
|
243
290
|
if res==[]: res = None
|
|
244
291
|
if res in (empty,None): res = data.get(arg, None)
|
|
292
|
+
if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = vals.get(arg) # htmx v4 nests form data under `values`
|
|
245
293
|
if res in (empty,None):
|
|
246
294
|
if p.default is empty:
|
|
247
295
|
if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}")
|
|
@@ -274,16 +322,18 @@ def flat_xt(lst):
|
|
|
274
322
|
|
|
275
323
|
# %% ../nbs/api/00_core.ipynb #aacff5ac
|
|
276
324
|
class Beforeware:
|
|
325
|
+
"Wrap a `before` function with `skip` path patterns that bypass it"
|
|
277
326
|
def __init__(self, f, skip=None): self.f,self.skip = f,skip or []
|
|
278
327
|
def __repr__(self): return f'Beforeware({self.f}, skip={self.skip})'
|
|
279
328
|
|
|
280
329
|
# %% ../nbs/api/00_core.ipynb #78c3c357
|
|
281
|
-
async def _handle(f, *args, **kwargs):
|
|
330
|
+
async def _handle(f, /, *args, **kwargs):
|
|
282
331
|
return (await f(*args, **kwargs)) if is_async_callable(f) else await run_in_threadpool(f, *args, **kwargs)
|
|
283
332
|
|
|
284
333
|
# %% ../nbs/api/00_core.ipynb #ad0f0e87
|
|
285
334
|
async def _wrap_ws(ws, data, params):
|
|
286
|
-
|
|
335
|
+
raw = data.pop('HEADERS', {}) or (data.pop('headers') if isinstance(data.get('headers'), dict) else {}) # htmx v4 sends lowercase `headers`
|
|
336
|
+
hdrs = Headers({k.lower():v for k,v in raw.items() if v is not None})
|
|
287
337
|
return await _find_ps(ws, data, hdrs, params)
|
|
288
338
|
|
|
289
339
|
# %% ../nbs/api/00_core.ipynb #dcc15129
|
|
@@ -321,6 +371,7 @@ def EventStream(s):
|
|
|
321
371
|
|
|
322
372
|
# %% ../nbs/api/00_core.ipynb #0dd0a414
|
|
323
373
|
def signal_shutdown():
|
|
374
|
+
"Return an `asyncio.Event` set when uvicorn receives a shutdown signal"
|
|
324
375
|
from uvicorn.main import Server
|
|
325
376
|
event = asyncio.Event()
|
|
326
377
|
@patch
|
|
@@ -493,6 +544,7 @@ async def _wrap_call(f, req, params):
|
|
|
493
544
|
return await _handle(f, **wreq)
|
|
494
545
|
|
|
495
546
|
# %% ../nbs/api/00_core.ipynb #b0d1cbbf
|
|
547
|
+
# chkstyle: skip
|
|
496
548
|
htmx_exts = {
|
|
497
549
|
"morph": "https://cdn.jsdelivr.net/npm/idiomorph@0.7.3/dist/idiomorph-ext.min.js",
|
|
498
550
|
"head-support": "https://cdn.jsdelivr.net/npm/htmx-ext-head-support@2.0.4/head-support.js",
|
|
@@ -504,11 +556,16 @@ htmx_exts = {
|
|
|
504
556
|
"remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js",
|
|
505
557
|
"debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js",
|
|
506
558
|
"ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js",
|
|
507
|
-
"
|
|
559
|
+
"ws4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-ws.min.js",
|
|
560
|
+
"chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js",
|
|
561
|
+
"sse4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js",
|
|
562
|
+
"live": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js",
|
|
563
|
+
"multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js"
|
|
508
564
|
}
|
|
509
565
|
|
|
510
566
|
# %% ../nbs/api/00_core.ipynb #60cb52ea
|
|
511
567
|
htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js")
|
|
568
|
+
htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js")
|
|
512
569
|
fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js")
|
|
513
570
|
surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js")
|
|
514
571
|
scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js")
|
|
@@ -553,26 +610,43 @@ def qp(p:str, **kw) -> str:
|
|
|
553
610
|
return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')
|
|
554
611
|
|
|
555
612
|
# %% ../nbs/api/00_core.ipynb #f86690c4
|
|
556
|
-
def def_hdrs(htmx=True, surreal=True):
|
|
613
|
+
def def_hdrs(htmx=True, htmx4=False, surreal=True):
|
|
557
614
|
"Default headers for a FastHTML app"
|
|
558
615
|
hdrs = []
|
|
559
616
|
if surreal: hdrs = [surrsrc,scopesrc] + hdrs
|
|
560
|
-
if
|
|
617
|
+
if htmx4:
|
|
618
|
+
# metaCharacter="-" makes htmx4 use dashes instead of colons
|
|
619
|
+
meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"}))
|
|
620
|
+
hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs
|
|
621
|
+
# TODO: Check if fhjsscr works with htmx4
|
|
622
|
+
elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs
|
|
561
623
|
return [charset, viewport] + hdrs
|
|
562
624
|
|
|
563
625
|
# %% ../nbs/api/00_core.ipynb #2c5285ae
|
|
564
|
-
cors_allow = Middleware(CORSMiddleware, allow_credentials=True,
|
|
565
|
-
allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
|
626
|
+
cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
|
|
566
627
|
|
|
567
628
|
iframe_scr = Script(NotStr("""
|
|
568
629
|
function sendmsg() {
|
|
569
630
|
window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');
|
|
570
631
|
}
|
|
632
|
+
|
|
571
633
|
window.onload = function() {
|
|
572
634
|
sendmsg();
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
635
|
+
|
|
636
|
+
const ver = window.htmx?.version || '';
|
|
637
|
+
const isV4 = ver.startsWith('4.');
|
|
638
|
+
const mc = window.htmx?.config?.metaCharacter || ':';
|
|
639
|
+
|
|
640
|
+
if (isV4) {
|
|
641
|
+
document.body.addEventListener(`htmx${mc}after${mc}swap`, sendmsg);
|
|
642
|
+
document.body.addEventListener(`htmx${mc}after${mc}ws${mc}message`, sendmsg);
|
|
643
|
+
} else {
|
|
644
|
+
document.body.addEventListener('htmx:afterSettle', sendmsg);
|
|
645
|
+
document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
"""))
|
|
649
|
+
|
|
576
650
|
|
|
577
651
|
# %% ../nbs/api/00_core.ipynb #17ced9a3
|
|
578
652
|
class _LifespanCtx:
|
|
@@ -585,6 +659,7 @@ class _LifespanCtx:
|
|
|
585
659
|
async def __anext__(self): return await self.gen.__anext__()
|
|
586
660
|
|
|
587
661
|
class Lifespan:
|
|
662
|
+
"Combine `on_startup`/`on_shutdown` callbacks and an optional lifespan generator into one context manager"
|
|
588
663
|
def __init__(self, startup=None, shutdown=None, ls=None):
|
|
589
664
|
startup,shutdown = listify(startup),listify(shutdown)
|
|
590
665
|
store_attr()
|
|
@@ -598,26 +673,27 @@ class Lifespan:
|
|
|
598
673
|
else: yield
|
|
599
674
|
for f in self.shutdown: await _handle(f)
|
|
600
675
|
|
|
601
|
-
def on_event(self, event_type):
|
|
602
|
-
return lambda f: (getattr(self, event_type)).append(f)
|
|
676
|
+
def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)
|
|
603
677
|
|
|
604
678
|
# %% ../nbs/api/00_core.ipynb #3327a1e9
|
|
605
679
|
class FastHTML(Starlette):
|
|
680
|
+
"An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments"
|
|
606
681
|
def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None,
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
682
|
+
on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,
|
|
683
|
+
before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,
|
|
684
|
+
secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',
|
|
685
|
+
same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',
|
|
686
|
+
body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw):
|
|
612
687
|
middleware,before,after = map(_list, (middleware,before,after))
|
|
613
688
|
self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname
|
|
614
689
|
hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts))
|
|
690
|
+
if htmx4 and exts: exts = ['ws4' if e in ('ws', 'ws4') else e for e in exts]
|
|
615
691
|
exts = {k:htmx_exts[k] for k in exts}
|
|
616
692
|
htmlkw = htmlkw or {}
|
|
617
|
-
if default_hdrs: hdrs = def_hdrs(htmx, surreal=surreal) + hdrs
|
|
693
|
+
if default_hdrs: hdrs = def_hdrs(htmx, htmx4, surreal=surreal) + hdrs
|
|
618
694
|
hdrs += [Script(src=ext) for ext in exts.values()]
|
|
619
695
|
if IN_NOTEBOOK:
|
|
620
|
-
hdrs.append(iframe_scr)
|
|
696
|
+
hdrs.append(iframe_scr) # TODO: check iframe_scr work with htmx4
|
|
621
697
|
from IPython.display import display,HTML
|
|
622
698
|
if nb_hdrs: display(HTML(to_xml(tuple(hdrs))))
|
|
623
699
|
middleware.append(cors_allow)
|
|
@@ -626,9 +702,8 @@ class FastHTML(Starlette):
|
|
|
626
702
|
self.body_wrap,self.before,self.after,self.htmlkw,self.bodykw,self.max_part_size = body_wrap,before,after,htmlkw,bodykw,max_part_size
|
|
627
703
|
self.secret_key = get_key(secret_key, key_fname)
|
|
628
704
|
if sess_cls:
|
|
629
|
-
sess = Middleware(sess_cls, secret_key=self.secret_key,session_cookie=session_cookie,
|
|
630
|
-
|
|
631
|
-
https_only=sess_https_only, domain=sess_domain)
|
|
705
|
+
sess = Middleware(sess_cls, secret_key=self.secret_key, session_cookie=session_cookie, max_age=max_age, path=sess_path,
|
|
706
|
+
same_site=same_site, https_only=sess_https_only, domain=sess_domain)
|
|
632
707
|
middleware.append(sess)
|
|
633
708
|
exception_handlers = ifnone(exception_handlers, {})
|
|
634
709
|
if 404 not in exception_handlers:
|
|
@@ -663,8 +738,8 @@ def add_route(self:FastHTML, route):
|
|
|
663
738
|
"Add or replace a route in the FastHTML app"
|
|
664
739
|
route.methods = [m.upper() for m in listify(route.methods)]
|
|
665
740
|
self.router.routes = [r for r in self.router.routes if not
|
|
666
|
-
|
|
667
|
-
|
|
741
|
+
(r.path==route.path and r.name == route.name and
|
|
742
|
+
((route.methods is None) or (set(r.methods) == set(route.methods))))]
|
|
668
743
|
self.router.routes.append(route)
|
|
669
744
|
self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))
|
|
670
745
|
|
|
@@ -688,8 +763,7 @@ def _endp(self:FastHTML, f, body_wrap, before:Optional[Callable|tuple]=None):
|
|
|
688
763
|
if not resp:
|
|
689
764
|
if isinstance(b, Beforeware): bf,skip = b.f,b.skip
|
|
690
765
|
else: bf,skip = b,[]
|
|
691
|
-
if not any(re.fullmatch(r, req.url.path) for r in skip):
|
|
692
|
-
resp = await _wrap_call(bf, req, _params(bf))
|
|
766
|
+
if not any(re.fullmatch(r, req.url.path) for r in skip): resp = await _wrap_call(bf, req, _params(bf))
|
|
693
767
|
for b in listify(before):
|
|
694
768
|
if not resp: resp = await _wrap_call(b, req, _params(b))
|
|
695
769
|
req.body_wrap = body_wrap
|
|
@@ -800,27 +874,37 @@ def set_lifespan(self:FastHTML, value):
|
|
|
800
874
|
|
|
801
875
|
# %% ../nbs/api/00_core.ipynb #3a348474
|
|
802
876
|
def serve(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
877
|
+
appname=None, # Name of the module
|
|
878
|
+
app='app', # App instance to be served
|
|
879
|
+
host='0.0.0.0', # If host is 0.0.0.0 will convert to localhost
|
|
880
|
+
port=None, # If port is None it will default to 5001 or the PORT environment variable
|
|
881
|
+
reload=True, # Default is to reload the app upon code changes
|
|
882
|
+
procs=None, # Processes to run on consecutive ports from `port`; default 1 or the PROCS environment variable
|
|
883
|
+
**kwargs
|
|
809
884
|
):
|
|
810
885
|
"Run the app in an async server, with live reload set as the default."
|
|
811
886
|
from uvicorn import run
|
|
812
887
|
bk = inspect.currentframe().f_back
|
|
813
|
-
glb = bk.f_globals
|
|
814
|
-
code = bk.f_code
|
|
888
|
+
glb,back = bk.f_globals,bk.f_back
|
|
815
889
|
if not appname:
|
|
816
890
|
if glb.get('__name__')=='__main__': appname = Path(glb.get('__file__', '')).stem
|
|
817
|
-
elif
|
|
891
|
+
elif back and back.f_globals.get('__name__')=='__main__': appname = inspect.getmodule(bk).__name__
|
|
818
892
|
if appname:
|
|
819
893
|
if not port: port=int(os.getenv("PORT", default=5001))
|
|
894
|
+
if not procs: procs=int(os.getenv("PROCS", default=1))
|
|
895
|
+
if procs>1 and 'FH_PROCNUM' not in os.environ: return _spawn(procs)
|
|
896
|
+
port += int(os.getenv('FH_PROCNUM', 0))
|
|
820
897
|
link = f'http://{"localhost" if host=="0.0.0.0" else host}:{port}'
|
|
821
898
|
print('Link: '+ S.light_red.bold(link))
|
|
822
899
|
run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)
|
|
823
900
|
|
|
901
|
+
# %% ../nbs/api/00_core.ipynb #91b08e9a
|
|
902
|
+
def _spawn(n):
|
|
903
|
+
"Run this script `n` times over, numbering each copy in `FH_PROCNUM`, and wait for them all"
|
|
904
|
+
ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)]
|
|
905
|
+
signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])
|
|
906
|
+
for p in ps: p.wait()
|
|
907
|
+
|
|
824
908
|
# %% ../nbs/api/00_core.ipynb #c5220a0e
|
|
825
909
|
async def _wait_disconnect(req):
|
|
826
910
|
while (await req.receive())["type"] != "http.disconnect": pass
|
|
@@ -865,6 +949,7 @@ for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o
|
|
|
865
949
|
|
|
866
950
|
# %% ../nbs/api/00_core.ipynb #d5223a9a
|
|
867
951
|
class RouteFuncs:
|
|
952
|
+
"Attr-access store of named route functions (HTTP verb names excluded)"
|
|
868
953
|
def __init__(self): super().__setattr__('_funcs', {})
|
|
869
954
|
def __setattr__(self, name, value): self._funcs[name] = value
|
|
870
955
|
def __getattr__(self, name):
|
|
@@ -922,8 +1007,7 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None
|
|
|
922
1007
|
cookie = cookies.SimpleCookie()
|
|
923
1008
|
cookie[key] = value
|
|
924
1009
|
if max_age is not None: cookie[key]["max-age"] = max_age
|
|
925
|
-
if expires is not None:
|
|
926
|
-
cookie[key]["expires"] = format_datetime(expires, usegmt=True) if isinstance(expires, datetime) else expires
|
|
1010
|
+
if expires is not None: cookie[key]["expires"] = format_datetime(expires, usegmt=True) if isinstance(expires, datetime) else expires
|
|
927
1011
|
if path is not None: cookie[key]["path"] = path
|
|
928
1012
|
if domain is not None: cookie[key]["domain"] = domain
|
|
929
1013
|
if secure: cookie[key]["secure"] = True
|
|
@@ -936,6 +1020,7 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None
|
|
|
936
1020
|
|
|
937
1021
|
# %% ../nbs/api/00_core.ipynb #8816f277
|
|
938
1022
|
def reg_re_param(m, s):
|
|
1023
|
+
"Register a Starlette URL convertor named `m` matching regex `s`"
|
|
939
1024
|
cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)
|
|
940
1025
|
register_url_convertor(m, cls())
|
|
941
1026
|
|
|
@@ -960,6 +1045,7 @@ def static_route(self:FastHTML, ext='', prefix='/', static_path='.'):
|
|
|
960
1045
|
|
|
961
1046
|
# %% ../nbs/api/00_core.ipynb #f63b7a03
|
|
962
1047
|
class StaticNoCache(StaticFiles):
|
|
1048
|
+
"StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use"
|
|
963
1049
|
def file_response(self, *args, **kwargs):
|
|
964
1050
|
resp = super().file_response(*args, **kwargs)
|
|
965
1051
|
resp.headers.setdefault("Cache-Control", "no-cache")
|
|
@@ -995,7 +1081,7 @@ def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Pa
|
|
|
995
1081
|
sig = signature(f)
|
|
996
1082
|
if name in sig.parameters: return f
|
|
997
1083
|
kw = {} if default is Parameter.empty else {'default': default}
|
|
998
|
-
new_params = list(sig.parameters.values()) + [Parameter(name, kind, **kw)]
|
|
1084
|
+
new_params = list(sig.parameters.values()) + [Parameter(name, kind, annotation=typ, **kw)]
|
|
999
1085
|
f.__signature__ = sig.replace(parameters=new_params)
|
|
1000
1086
|
f.__annotations__[name] = typ
|
|
1001
1087
|
return f
|
|
@@ -1024,11 +1110,11 @@ class into:
|
|
|
1024
1110
|
|
|
1025
1111
|
# %% ../nbs/api/00_core.ipynb #1960d7ff
|
|
1026
1112
|
class MiddlewareBase:
|
|
1113
|
+
"Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`"
|
|
1027
1114
|
async def __call__(self, scope, receive, send) -> None:
|
|
1028
|
-
if scope["type"]
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
return HTTPConnection(scope)
|
|
1115
|
+
if scope["type"] in ("http", "websocket"): return HTTPConnection(scope)
|
|
1116
|
+
await self._app(scope, receive, send)
|
|
1117
|
+
|
|
1032
1118
|
|
|
1033
1119
|
# %% ../nbs/api/00_core.ipynb #83a20f93
|
|
1034
1120
|
class FtResponse:
|
|
@@ -1047,6 +1133,7 @@ class FtResponse:
|
|
|
1047
1133
|
|
|
1048
1134
|
# %% ../nbs/api/00_core.ipynb #9dc1025e
|
|
1049
1135
|
def unqid(seeded=False):
|
|
1136
|
+
"Random unique id, base64-encoded and prefixed with '_' so it's usable as an HTML id"
|
|
1050
1137
|
id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4()
|
|
1051
1138
|
res = b64encode(id4.bytes)
|
|
1052
1139
|
return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))
|
|
@@ -1077,8 +1164,7 @@ def devtools_json(self:FastHTML, path=None, uuid=None):
|
|
|
1077
1164
|
if not path: path = Path().absolute()
|
|
1078
1165
|
if not uuid: uuid = str(uuid5(NAMESPACE_URL, str(path)))
|
|
1079
1166
|
@self.route(devtools_loc)
|
|
1080
|
-
def devtools():
|
|
1081
|
-
return dict(workspace=dict(root=str(path), uuid=uuid))
|
|
1167
|
+
def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))
|
|
1082
1168
|
|
|
1083
1169
|
# %% ../nbs/api/00_core.ipynb #e27908c0
|
|
1084
1170
|
@patch
|
|
@@ -1110,8 +1196,7 @@ def get_testclient(self:FastHTML, **kw):
|
|
|
1110
1196
|
"A Starlette TestClient with a `session` property"
|
|
1111
1197
|
@property
|
|
1112
1198
|
def session(self):
|
|
1113
|
-
cookie = next((c.value for c in reversed(list(self.cookies.jar))
|
|
1114
|
-
if c.name == self.app.session_cookie), None)
|
|
1199
|
+
cookie = next((c.value for c in reversed(list(self.cookies.jar)) if c.name == self.app.session_cookie), None)
|
|
1115
1200
|
return self.app.decode_session(cookie)
|
|
1116
1201
|
|
|
1117
1202
|
return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)
|
|
@@ -44,6 +44,7 @@ def fast_app(
|
|
|
44
44
|
pico:Optional[bool]=None, # Include PicoCSS header?
|
|
45
45
|
surreal:Optional[bool]=True, # Include surreal.js/scope headers?
|
|
46
46
|
htmx:Optional[bool]=True, # Include HTMX header?
|
|
47
|
+
htmx4:Optional[bool]=False,
|
|
47
48
|
exts:Optional[list|str]=None, # HTMX extension names to include
|
|
48
49
|
canonical:bool=True, # Automatically include canonical link?
|
|
49
50
|
secret_key:Optional[str]=None, # Signing key for sessions
|
|
@@ -70,7 +71,7 @@ def fast_app(
|
|
|
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,
|
|
71
72
|
on_startup=on_startup, on_shutdown=on_shutdown, lifespan=lifespan, default_hdrs=default_hdrs, secret_key=secret_key, canonical=canonical,
|
|
72
73
|
session_cookie=session_cookie, max_age=max_age, sess_path=sess_path, same_site=same_site, sess_https_only=sess_https_only,
|
|
73
|
-
sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmlkw=htmlkw,
|
|
74
|
+
sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmx4=htmx4, htmlkw=htmlkw,
|
|
74
75
|
reload_attempts=reload_attempts, reload_interval=reload_interval, body_wrap=body_wrap, nb_hdrs=nb_hdrs, **(bodykw or {}))
|
|
75
76
|
app.static_route_exts(static_path=static_path)
|
|
76
77
|
if not db_file: return app,app.route
|
|
@@ -86,4 +87,3 @@ def fast_app(
|
|
|
86
87
|
dbtbls = [_get_tbl(db.t, k, v) for k,v in tbls.items()]
|
|
87
88
|
if len(dbtbls)==1: dbtbls=dbtbls[0]
|
|
88
89
|
return app,app.route,*dbtbls
|
|
89
|
-
|
|
@@ -55,13 +55,17 @@ def render_ft(**kw):
|
|
|
55
55
|
def htmx_config_port(port=8000):
|
|
56
56
|
display(HTML('''
|
|
57
57
|
<script>
|
|
58
|
-
document.body.addEventListener(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
58
|
+
["htmx:configRequest","htmx-config-request"].forEach(evt => document.body.addEventListener(evt, (event) => {
|
|
59
|
+
const ctx = event.detail.ctx;
|
|
60
|
+
const path = ctx ? ctx.request.action : event.detail.path;
|
|
61
|
+
if (path.includes('://')) return;
|
|
62
|
+
const full = `${location.protocol}//${location.hostname}:%s${path}`;
|
|
63
|
+
if (ctx) { ctx.request.mode = "cors"; ctx.request.action = full; }
|
|
64
|
+
else { htmx.config.selfRequestsOnly = false; event.detail.path = full; }
|
|
65
|
+
}));
|
|
63
66
|
</script>''' % port))
|
|
64
67
|
|
|
68
|
+
|
|
65
69
|
# %% ../nbs/api/06_jupyter.ipynb #79406618
|
|
66
70
|
class JupyUvi:
|
|
67
71
|
"Start and stop a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`"
|
|
@@ -73,7 +77,7 @@ class JupyUvi:
|
|
|
73
77
|
if live: self._setup_live(app)
|
|
74
78
|
if start: self.start()
|
|
75
79
|
if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port)
|
|
76
|
-
|
|
80
|
+
|
|
77
81
|
def start(self):
|
|
78
82
|
self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port,daemon=self.daemon, **self.kwargs)
|
|
79
83
|
|
|
@@ -21,10 +21,10 @@ try: from IPython import display
|
|
|
21
21
|
except ImportError: display=None
|
|
22
22
|
|
|
23
23
|
# %% ../nbs/api/04_pico.ipynb #100414ae
|
|
24
|
-
picocss = "https://cdn.jsdelivr.net/npm/@
|
|
24
|
+
picocss = "https://cdn.jsdelivr.net/npm/@anyblades/pico@latest/css/pico.min.css"
|
|
25
25
|
picolink = (Link(rel="stylesheet", href=picocss),
|
|
26
26
|
Style(":root { --pico-font-size: 100%; }"))
|
|
27
|
-
picocondcss = "https://cdn.jsdelivr.net/npm/@
|
|
27
|
+
picocondcss = "https://cdn.jsdelivr.net/npm/@anyblades/pico@latest/css/pico.conditional.min.css"
|
|
28
28
|
picocondlink = (Link(rel="stylesheet", href=picocondcss),
|
|
29
29
|
Style(":root { --pico-font-size: 100%; }"))
|
|
30
30
|
|
|
@@ -37,8 +37,7 @@ new MutationObserver(ms => {
|
|
|
37
37
|
ms.forEach(m => {
|
|
38
38
|
m.addedNodes.forEach(n => {
|
|
39
39
|
if (n.nodeType === 1) {
|
|
40
|
-
|
|
41
|
-
if (nc && (nc.contains('cell-output') || nc.contains('output_area'))) nc.add('pico');
|
|
40
|
+
if (n.matches(sel)) n.classList.add('pico');
|
|
42
41
|
n.querySelectorAll(sel).forEach(e => e.classList.add('pico'));
|
|
43
42
|
}
|
|
44
43
|
});
|
|
@@ -86,7 +85,8 @@ def Container(*args, **kwargs)->FT:
|
|
|
86
85
|
"A PicoCSS Container, implemented as a Main with class 'container'"
|
|
87
86
|
return Main(*args, cls="container", **kwargs)
|
|
88
87
|
|
|
89
|
-
# %% ../nbs/api/04_pico.ipynb #
|
|
88
|
+
# %% ../nbs/api/04_pico.ipynb #b8c98614
|
|
90
89
|
def PicoBusy():
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
elt = "(event.detail.ctx?.sourceElement || event.detail.elt)"
|
|
91
|
+
return (HtmxOn('beforeRequest', f"{elt}.setAttribute('aria-busy', 'true' )"),
|
|
92
|
+
HtmxOn('afterRequest', f"{elt}.setAttribute('aria-busy', 'false')"))
|
|
@@ -167,9 +167,10 @@ def run_js(js, id=None, **kw):
|
|
|
167
167
|
|
|
168
168
|
# %% ../nbs/api/02_xtend.ipynb #365f57a8
|
|
169
169
|
def HtmxOn(eventname:str, code:str):
|
|
170
|
+
"Run `code` on htmx event `eventname`; both the v2 (`htmx:camelCase`) and v4 (`htmx-dash-case`) spellings are bound, and only the loaded version's fires"
|
|
170
171
|
return Script('''domReadyExecute(function() {
|
|
171
|
-
document.body.addEventListener(
|
|
172
|
-
})''' % (eventname, code))
|
|
172
|
+
["htmx:%s","htmx-%s"].forEach(evt => document.body.addEventListener(evt, function(event) { %s }))
|
|
173
|
+
})''' % (to_camel(eventname), to_kebab(eventname), code))
|
|
173
174
|
|
|
174
175
|
# %% ../nbs/api/02_xtend.ipynb #39f20784
|
|
175
176
|
def jsd(org, repo, root, path, prov='gh', typ='script', ver=None, esm=False, **kwargs)->FT:
|
|
@@ -242,23 +243,24 @@ def Favicon(light_icon, dark_icon):
|
|
|
242
243
|
# %% ../nbs/api/02_xtend.ipynb #50444181
|
|
243
244
|
def clear(id): return Div(hx_swap_oob='innerHTML', id=id)
|
|
244
245
|
|
|
245
|
-
# %% ../nbs/api/02_xtend.ipynb #
|
|
246
|
-
sid_scr = Script(
|
|
246
|
+
# %% ../nbs/api/02_xtend.ipynb #f46160fb
|
|
247
|
+
sid_scr = Script("""
|
|
247
248
|
function uuid() {
|
|
248
249
|
return [...crypto.getRandomValues(new Uint8Array(10))].map(b=>b.toString(36)).join('');
|
|
249
250
|
}
|
|
250
|
-
|
|
251
251
|
sessionStorage.setItem("sid", sessionStorage.getItem("sid") || uuid());
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
253
|
+
function addSid(url, sid) {
|
|
254
|
+
const u = new URL(url, window.location.origin);
|
|
255
|
+
u.searchParams.set('sid', sid);
|
|
256
|
+
return u.pathname + u.search;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const mc = htmx.config?.metaCharacter || ':';
|
|
260
|
+
|
|
261
|
+
htmx.on("htmx:configRequest", (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.path = addSid(e.detail.path, sid); }); // htmx v2
|
|
262
|
+
htmx.on(`htmx${mc}config${mc}request`, (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4
|
|
263
|
+
""")
|
|
262
264
|
|
|
263
265
|
# %% ../nbs/api/02_xtend.ipynb #579e1f33
|
|
264
266
|
def with_sid(app, dest, path='/'):
|
|
@@ -12,7 +12,7 @@ license = {text = "Apache-2.0"}
|
|
|
12
12
|
authors = [{name = "Jeremy Howard and contributors", email = "github@jhoward.fastmail.fm"}]
|
|
13
13
|
keywords = ['nbdev', 'jupyter', 'notebook', 'python']
|
|
14
14
|
classifiers = ["Natural Language :: English", "Intended Audience :: Developers", "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
|
|
15
|
-
dependencies = ['fastcore>=2.
|
|
15
|
+
dependencies = ['fastcore>=2.2.7', 'python-dateutil', 'starlette>=1.0.1', 'oauthlib', 'itsdangerous', 'uvicorn[standard]>=0.30', 'httpx2', 'python-multipart', 'beautifulsoup4']
|
|
16
16
|
|
|
17
17
|
[project.urls]
|
|
18
18
|
Repository = "https://github.com/AnswerDotAI/fasthtml"
|
|
@@ -45,4 +45,3 @@ lib_path = "fasthtml"
|
|
|
45
45
|
[tool.chkstyle]
|
|
46
46
|
skip_paths = ["_modidx.py"]
|
|
47
47
|
ignore = ["closing-bracket", "inefficient-multiline-signature", "inefficient-multiline-expression"]
|
|
48
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-fasthtml
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.13
|
|
4
4
|
Summary: The fastest way to create an HTML app
|
|
5
5
|
Author-email: Jeremy Howard and contributors <github@jhoward.fastmail.fm>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: fastcore>=2.
|
|
18
|
+
Requires-Dist: fastcore>=2.2.7
|
|
19
19
|
Requires-Dist: python-dateutil
|
|
20
20
|
Requires-Dist: starlette>=1.0.1
|
|
21
21
|
Requires-Dist: oauthlib
|
|
@@ -128,7 +128,6 @@ The capabilities of FastHTML are vast and growing, and not all the features and
|
|
|
128
128
|
Then explore the small but growing third-party ecosystem of FastHTML tutorials, notebooks, libraries, and components:
|
|
129
129
|
|
|
130
130
|
- [FastHTML Gallery](https://gallery.fastht.ml): Learn from minimal examples of components (ie chat bubbles, click-to-edit, infinite scroll, etc)
|
|
131
|
-
- [Creating Custom FastHTML Tags for Markdown Rendering](https://isaac-flath.github.io/website/posts/boots/FasthtmlTutorial.html) by Isaac Flath
|
|
132
131
|
- [How to Build a Simple Login System in FastHTML](https://blog.mariusvach.com/posts/login-fasthtml) by Marius Vach
|
|
133
132
|
- Your tutorial here!
|
|
134
133
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|