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.
Files changed (39) hide show
  1. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/CONTRIBUTING.md +1 -1
  2. {python_fasthtml-0.14.11/python_fasthtml.egg-info → python_fasthtml-0.14.13}/PKG-INFO +2 -3
  3. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/README.md +0 -1
  4. python_fasthtml-0.14.13/fasthtml/__init__.py +8 -0
  5. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/_modidx.py +1 -0
  6. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/components.py +23 -14
  7. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/core.py +137 -52
  8. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/fastapp.py +2 -2
  9. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/jupyter.py +10 -6
  10. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/pico.py +7 -7
  11. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/xtend.py +16 -14
  12. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/pyproject.toml +1 -2
  13. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13/python_fasthtml.egg-info}/PKG-INFO +2 -3
  14. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/requires.txt +1 -1
  15. python_fasthtml-0.14.11/fasthtml/__init__.py +0 -2
  16. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/LICENSE +0 -0
  17. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/MANIFEST.in +0 -0
  18. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/authmw.py +0 -0
  19. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/basics.py +0 -0
  20. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/cli.py +0 -0
  21. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/common.py +0 -0
  22. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/components.pyi +0 -0
  23. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/core.pyi +0 -0
  24. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/ft.py +0 -0
  25. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/js.py +0 -0
  26. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/katex.js +0 -0
  27. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/live_reload.py +0 -0
  28. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/oauth.py +0 -0
  29. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/starlette.py +0 -0
  30. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/stripe_otp.py +0 -0
  31. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/svg.py +0 -0
  32. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/toaster.py +0 -0
  33. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/fasthtml/xtend.pyi +0 -0
  34. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/SOURCES.txt +0 -0
  35. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/dependency_links.txt +0 -0
  36. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/entry_points.txt +0 -0
  37. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/python_fasthtml.egg-info/top_level.txt +0 -0
  38. {python_fasthtml-0.14.11 → python_fasthtml-0.14.13}/setup.cfg +0 -0
  39. {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='nbdev_export && nbdev_clean && nbdev_trust'
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.11
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.1.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
 
@@ -0,0 +1,8 @@
1
+ """The fastest way to create an HTML app
2
+
3
+ Modules:
4
+
5
+ - `fasthtml.core`: The `FastHTML` subclass of `Starlette`."""
6
+
7
+ __version__ = "0.14.13"
8
+ from .core import *
@@ -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', 'hx_evts', 'js_evts', 'hx_attrs_annotations', 'hx_evt_attrs', 'js_evt_attrs',
9
- 'evt_attrs', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show', 'fill_form', 'fill_dataclass', 'find_inputs',
10
- 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area', 'Article', 'Aside', 'Audio', 'B', 'Base', 'Bdi',
11
- 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas', 'Caption', 'Cite', 'Code', 'Col', 'Colgroup', 'Data',
12
- 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog', 'Div', 'Dl', 'Dt', 'Em', 'Embed', 'Fencedframe',
13
- 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'Head', 'Header',
14
- 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd', 'Label', 'Legend', 'Li', 'Link', 'Main', 'Map',
15
- 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object', 'Ol', 'Optgroup', 'Option', 'Output', 'P',
16
- 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp', 'Rt', 'Ruby', 'S', 'Samp', 'Script', 'Search',
17
- 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span', 'Strong', 'Style', 'Sub', 'Summary', 'Sup', 'Table',
18
- 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul',
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', 'fhjsscr', 'surrsrc', 'scopesrc',
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; target:str|None=None; trigger_name:str|None=None; trigger: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: k:str;v:str
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
- reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)
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
- hdrs = Headers({k.lower():v for k,v in data.pop('HEADERS', {}).items() if v is not None})
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
- "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js"
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 htmx: hdrs = [htmxsrc,fhjsscr] + hdrs
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
- document.body.addEventListener('htmx:afterSettle', sendmsg);
574
- document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
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
- on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,
608
- before=None, after=None, surreal=True, htmx=True, default_hdrs=True, sess_cls=SessionMiddleware,
609
- secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',
610
- same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',
611
- body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw):
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
- max_age=max_age, path=sess_path, same_site=same_site,
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
- (r.path==route.path and r.name == route.name and
667
- ((route.methods is None) or (set(r.methods) == set(route.methods))))]
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
- appname=None, # Name of the module
804
- app='app', # App instance to be served
805
- host='0.0.0.0', # If host is 0.0.0.0 will convert to localhost
806
- port=None, # If port is None it will default to 5001 or the PORT environment variable
807
- reload=True, # Default is to reload the app upon code changes
808
- **kwargs
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 code.co_name=='main' and bk.f_back.f_globals.get('__name__')=='__main__': appname = inspect.getmodule(bk).__name__
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"] not in ["http", "websocket"]:
1029
- await self._app(scope, receive, send)
1030
- return
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('htmx:configRequest', (event) => {
59
- if(event.detail.path.includes('://')) return;
60
- htmx.config.selfRequestsOnly=false;
61
- event.detail.path = `${location.protocol}//${location.hostname}:%s${event.detail.path}`;
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/@picocss/pico@latest/css/pico.min.css"
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/@picocss/pico@latest/css/pico.conditional.min.css"
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
- var nc = n.classList;
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 #138dc298
88
+ # %% ../nbs/api/04_pico.ipynb #b8c98614
90
89
  def PicoBusy():
91
- return (HtmxOn('beforeRequest', "event.detail.elt.setAttribute('aria-busy', 'true' )"),
92
- HtmxOn('afterRequest', "event.detail.elt.setAttribute('aria-busy', 'false')"))
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("htmx:%s", function(event) { %s })
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 #09b2e3a5
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
- htmx.on("htmx:configRequest", (e) => {
254
- const sid = sessionStorage.getItem("sid");
255
- if (sid) {
256
- const url = new URL(e.detail.path, window.location.origin);
257
- url.searchParams.set('sid', sid);
258
- e.detail.path = url.pathname + url.search;
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.1.2', 'python-dateutil', 'starlette>=1.0.1', 'oauthlib', 'itsdangerous', 'uvicorn[standard]>=0.30', 'httpx2', 'python-multipart', 'beautifulsoup4']
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.11
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.1.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
 
@@ -1,4 +1,4 @@
1
- fastcore>=2.1.2
1
+ fastcore>=2.2.7
2
2
  python-dateutil
3
3
  starlette>=1.0.1
4
4
  oauthlib
@@ -1,2 +0,0 @@
1
- __version__ = "0.14.11"
2
- from .core import *