python-fasthtml 0.14.11__tar.gz → 0.14.12__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.egg-info → python_fasthtml-0.14.12}/PKG-INFO +2 -3
  2. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/README.md +0 -1
  3. python_fasthtml-0.14.12/fasthtml/__init__.py +8 -0
  4. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/components.py +23 -14
  5. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/core.py +107 -25
  6. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/fastapp.py +2 -2
  7. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/jupyter.py +10 -6
  8. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/pico.py +7 -7
  9. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/xtend.py +16 -14
  10. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/pyproject.toml +1 -2
  11. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12/python_fasthtml.egg-info}/PKG-INFO +2 -3
  12. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/python_fasthtml.egg-info/requires.txt +1 -1
  13. python_fasthtml-0.14.11/fasthtml/__init__.py +0 -2
  14. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/CONTRIBUTING.md +0 -0
  15. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/LICENSE +0 -0
  16. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/MANIFEST.in +0 -0
  17. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/_modidx.py +0 -0
  18. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/authmw.py +0 -0
  19. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/basics.py +0 -0
  20. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/cli.py +0 -0
  21. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/common.py +0 -0
  22. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/components.pyi +0 -0
  23. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/core.pyi +0 -0
  24. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/ft.py +0 -0
  25. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/js.py +0 -0
  26. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/katex.js +0 -0
  27. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/live_reload.py +0 -0
  28. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/oauth.py +0 -0
  29. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/starlette.py +0 -0
  30. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/stripe_otp.py +0 -0
  31. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/svg.py +0 -0
  32. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/toaster.py +0 -0
  33. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/fasthtml/xtend.pyi +0 -0
  34. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/python_fasthtml.egg-info/SOURCES.txt +0 -0
  35. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/python_fasthtml.egg-info/dependency_links.txt +0 -0
  36. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/python_fasthtml.egg-info/entry_points.txt +0 -0
  37. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/python_fasthtml.egg-info/top_level.txt +0 -0
  38. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/setup.cfg +0 -0
  39. {python_fasthtml-0.14.11 → python_fasthtml-0.14.12}/tests/test_toaster.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fasthtml
3
- Version: 0.14.11
3
+ Version: 0.14.12
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.12"
8
+ from .core import *
@@ -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',
@@ -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,7 +161,9 @@ 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()
@@ -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
@@ -504,11 +555,16 @@ htmx_exts = {
504
555
  "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js",
505
556
  "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js",
506
557
  "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"
558
+ "ws4": "https://unpkg.com/htmx.org@4.0.0-beta6/dist/ext/hx-ws.js",
559
+ "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js",
560
+ "sse4": "https://unpkg.com/htmx.org@4.0.0-beta6/dist/ext/hx-sse.js",
561
+ "live": "https://unpkg.com/htmx.org@4.0.0-beta6/dist/ext/hx-live.js",
562
+ "multipart": "https://cdn.jsdelivr.net/gh/bigskysoftware/htmx@four/src/ext/hx-multipart.min.js"
508
563
  }
509
564
 
510
565
  # %% ../nbs/api/00_core.ipynb #60cb52ea
511
566
  htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js")
567
+ htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta6/dist/htmx.js")
512
568
  fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js")
513
569
  surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js")
514
570
  scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js")
@@ -553,11 +609,17 @@ def qp(p:str, **kw) -> str:
553
609
  return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')
554
610
 
555
611
  # %% ../nbs/api/00_core.ipynb #f86690c4
556
- def def_hdrs(htmx=True, surreal=True):
612
+ def def_hdrs(htmx=True, htmx4=False, surreal=True):
557
613
  "Default headers for a FastHTML app"
558
614
  hdrs = []
559
615
  if surreal: hdrs = [surrsrc,scopesrc] + hdrs
560
- if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs
616
+ if htmx4:
617
+ # metaCharacter="-" makes htmx4 use dashes instead of colons
618
+ meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"}))
619
+ hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs
620
+ # TODO: Check if fhjsscr works with htmx4
621
+ elif htmx:
622
+ hdrs = [htmxsrc,fhjsscr] + hdrs
561
623
  return [charset, viewport] + hdrs
562
624
 
563
625
  # %% ../nbs/api/00_core.ipynb #2c5285ae
@@ -568,11 +630,24 @@ iframe_scr = Script(NotStr("""
568
630
  function sendmsg() {
569
631
  window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');
570
632
  }
633
+
571
634
  window.onload = function() {
572
635
  sendmsg();
573
- document.body.addEventListener('htmx:afterSettle', sendmsg);
574
- document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
575
- };"""))
636
+
637
+ const ver = window.htmx?.version || '';
638
+ const isV4 = ver.startsWith('4.');
639
+ const mc = window.htmx?.config?.metaCharacter || ':';
640
+
641
+ if (isV4) {
642
+ document.body.addEventListener(`htmx${mc}after${mc}swap`, sendmsg);
643
+ document.body.addEventListener(`htmx${mc}after${mc}ws${mc}message`, sendmsg);
644
+ } else {
645
+ document.body.addEventListener('htmx:afterSettle', sendmsg);
646
+ document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
647
+ }
648
+ };
649
+ """))
650
+
576
651
 
577
652
  # %% ../nbs/api/00_core.ipynb #17ced9a3
578
653
  class _LifespanCtx:
@@ -585,6 +660,7 @@ class _LifespanCtx:
585
660
  async def __anext__(self): return await self.gen.__anext__()
586
661
 
587
662
  class Lifespan:
663
+ "Combine `on_startup`/`on_shutdown` callbacks and an optional lifespan generator into one context manager"
588
664
  def __init__(self, startup=None, shutdown=None, ls=None):
589
665
  startup,shutdown = listify(startup),listify(shutdown)
590
666
  store_attr()
@@ -603,21 +679,24 @@ class Lifespan:
603
679
 
604
680
  # %% ../nbs/api/00_core.ipynb #3327a1e9
605
681
  class FastHTML(Starlette):
682
+ "An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments"
606
683
  def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None,
607
684
  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,
685
+ before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,
609
686
  secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',
610
687
  same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',
611
688
  body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw):
612
689
  middleware,before,after = map(_list, (middleware,before,after))
613
690
  self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname
614
691
  hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts))
692
+ if htmx4 and exts:
693
+ exts = ['ws4' if e in ('ws', 'ws4') else e for e in exts]
615
694
  exts = {k:htmx_exts[k] for k in exts}
616
695
  htmlkw = htmlkw or {}
617
- if default_hdrs: hdrs = def_hdrs(htmx, surreal=surreal) + hdrs
696
+ if default_hdrs: hdrs = def_hdrs(htmx, htmx4, surreal=surreal) + hdrs
618
697
  hdrs += [Script(src=ext) for ext in exts.values()]
619
698
  if IN_NOTEBOOK:
620
- hdrs.append(iframe_scr)
699
+ hdrs.append(iframe_scr) # TODO: check iframe_scr work with htmx4
621
700
  from IPython.display import display,HTML
622
701
  if nb_hdrs: display(HTML(to_xml(tuple(hdrs))))
623
702
  middleware.append(cors_allow)
@@ -627,8 +706,8 @@ class FastHTML(Starlette):
627
706
  self.secret_key = get_key(secret_key, key_fname)
628
707
  if sess_cls:
629
708
  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)
709
+ max_age=max_age, path=sess_path, same_site=same_site,
710
+ https_only=sess_https_only, domain=sess_domain)
632
711
  middleware.append(sess)
633
712
  exception_handlers = ifnone(exception_handlers, {})
634
713
  if 404 not in exception_handlers:
@@ -810,11 +889,10 @@ def serve(
810
889
  "Run the app in an async server, with live reload set as the default."
811
890
  from uvicorn import run
812
891
  bk = inspect.currentframe().f_back
813
- glb = bk.f_globals
814
- code = bk.f_code
892
+ glb,back = bk.f_globals,bk.f_back
815
893
  if not appname:
816
894
  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__
895
+ elif back and back.f_globals.get('__name__')=='__main__': appname = inspect.getmodule(bk).__name__
818
896
  if appname:
819
897
  if not port: port=int(os.getenv("PORT", default=5001))
820
898
  link = f'http://{"localhost" if host=="0.0.0.0" else host}:{port}'
@@ -865,6 +943,7 @@ for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o
865
943
 
866
944
  # %% ../nbs/api/00_core.ipynb #d5223a9a
867
945
  class RouteFuncs:
946
+ "Attr-access store of named route functions (HTTP verb names excluded)"
868
947
  def __init__(self): super().__setattr__('_funcs', {})
869
948
  def __setattr__(self, name, value): self._funcs[name] = value
870
949
  def __getattr__(self, name):
@@ -936,6 +1015,7 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None
936
1015
 
937
1016
  # %% ../nbs/api/00_core.ipynb #8816f277
938
1017
  def reg_re_param(m, s):
1018
+ "Register a Starlette URL convertor named `m` matching regex `s`"
939
1019
  cls = get_class(f'{m}Conv', sup=StringConvertor, regex=s)
940
1020
  register_url_convertor(m, cls())
941
1021
 
@@ -960,6 +1040,7 @@ def static_route(self:FastHTML, ext='', prefix='/', static_path='.'):
960
1040
 
961
1041
  # %% ../nbs/api/00_core.ipynb #f63b7a03
962
1042
  class StaticNoCache(StaticFiles):
1043
+ "StaticFiles that sends `Cache-Control: no-cache`, so browsers revalidate on every use"
963
1044
  def file_response(self, *args, **kwargs):
964
1045
  resp = super().file_response(*args, **kwargs)
965
1046
  resp.headers.setdefault("Cache-Control", "no-cache")
@@ -995,7 +1076,7 @@ def add_sig_param(f, name, typ=NoneType, kind=Parameter.KEYWORD_ONLY, default=Pa
995
1076
  sig = signature(f)
996
1077
  if name in sig.parameters: return f
997
1078
  kw = {} if default is Parameter.empty else {'default': default}
998
- new_params = list(sig.parameters.values()) + [Parameter(name, kind, **kw)]
1079
+ new_params = list(sig.parameters.values()) + [Parameter(name, kind, annotation=typ, **kw)]
999
1080
  f.__signature__ = sig.replace(parameters=new_params)
1000
1081
  f.__annotations__[name] = typ
1001
1082
  return f
@@ -1024,11 +1105,11 @@ class into:
1024
1105
 
1025
1106
  # %% ../nbs/api/00_core.ipynb #1960d7ff
1026
1107
  class MiddlewareBase:
1108
+ "Base for middleware: passes non-http/ws scopes through, else returns the `HTTPConnection`"
1027
1109
  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)
1110
+ if scope["type"] in ("http", "websocket"): return HTTPConnection(scope)
1111
+ await self._app(scope, receive, send)
1112
+
1032
1113
 
1033
1114
  # %% ../nbs/api/00_core.ipynb #83a20f93
1034
1115
  class FtResponse:
@@ -1047,6 +1128,7 @@ class FtResponse:
1047
1128
 
1048
1129
  # %% ../nbs/api/00_core.ipynb #9dc1025e
1049
1130
  def unqid(seeded=False):
1131
+ "Random unique id, base64-encoded and prefixed with '_' so it's usable as an HTML id"
1050
1132
  id4 = UUID(int=random.getrandbits(128), version=4) if seeded else uuid4()
1051
1133
  res = b64encode(id4.bytes)
1052
1134
  return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '_-'))
@@ -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.12
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 *