python-fasthtml 0.14.12__tar.gz → 0.14.14__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 (38) hide show
  1. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/CONTRIBUTING.md +5 -5
  2. {python_fasthtml-0.14.12/python_fasthtml.egg-info → python_fasthtml-0.14.14}/PKG-INFO +2 -2
  3. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/__init__.py +1 -1
  4. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/_modidx.py +2 -9
  5. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/core.py +58 -48
  6. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/oauth.py +4 -1
  7. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/pyproject.toml +2 -2
  8. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14/python_fasthtml.egg-info}/PKG-INFO +2 -2
  9. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/LICENSE +0 -0
  10. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/MANIFEST.in +0 -0
  11. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/README.md +0 -0
  12. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/authmw.py +0 -0
  13. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/basics.py +0 -0
  14. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/cli.py +0 -0
  15. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/common.py +0 -0
  16. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/components.py +0 -0
  17. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/components.pyi +0 -0
  18. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/core.pyi +0 -0
  19. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/fastapp.py +0 -0
  20. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/ft.py +0 -0
  21. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/js.py +0 -0
  22. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/jupyter.py +0 -0
  23. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/katex.js +0 -0
  24. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/live_reload.py +0 -0
  25. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/pico.py +0 -0
  26. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/starlette.py +0 -0
  27. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/stripe_otp.py +0 -0
  28. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/svg.py +0 -0
  29. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/toaster.py +0 -0
  30. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/xtend.py +0 -0
  31. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/fasthtml/xtend.pyi +0 -0
  32. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/python_fasthtml.egg-info/SOURCES.txt +0 -0
  33. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/python_fasthtml.egg-info/dependency_links.txt +0 -0
  34. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/python_fasthtml.egg-info/entry_points.txt +0 -0
  35. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/python_fasthtml.egg-info/requires.txt +0 -0
  36. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/python_fasthtml.egg-info/top_level.txt +0 -0
  37. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/setup.cfg +0 -0
  38. {python_fasthtml-0.14.12 → python_fasthtml-0.14.14}/tests/test_toaster.py +0 -0
@@ -3,15 +3,15 @@
3
3
  Make sure you have read the [doc on code style](
4
4
  https://docs.fast.ai/dev/style.html) first. (Note that we don't follow PEP8, but instead follow a coding style designed specifically for numerical and interactive programming.)
5
5
 
6
- This project uses [nbdev](https://nbdev.fast.ai/getting_started.html) for development. Before beginning, make sure that nbdev and a jupyter-compatible client such as jupyterlab or nbclassic are installed. To make changes to the codebase, update the notebooks in the `nbs` folder, not the .py files directly. Then, run `nbdev_export`. For more details, have a look at the [nbdev tutorial](https://nbdev.fast.ai/tutorials/tutorial.html). Depending on the code changes, you might also need to run `tools/update.sh` to update python interface modules and [LLM reference material](https://github.com/AnswerDotAI/llms-txt).
6
+ This project uses [nbdev](https://nbdev.fast.ai/getting_started.html) for development. Before beginning, make sure that nbdev and a jupyter-compatible client such as jupyterlab or nbclassic are installed. To make changes to the codebase, update the notebooks in the `nbs` folder, not the .py files directly. Then, run `nbdev-export`. For more details, have a look at the [nbdev tutorial](https://nbdev.fast.ai/tutorials/tutorial.html). Depending on the code changes, you might also need to run `tools/update.sh` to update python interface modules and [LLM reference material](https://github.com/AnswerDotAI/llms-txt).
7
7
 
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
- Run `prep` before each commit to ensure your python files are up to date, and you notebooks cleaned of metadata and notarized.
14
+ Run `prep` before each commit to ensure your python files are up to date, and your notebooks cleaned of metadata and notarized.
15
15
 
16
16
  ## Updating README.md
17
17
 
@@ -26,13 +26,13 @@ pip install -e '.[dev]'
26
26
  And install quarto too:
27
27
 
28
28
  ```
29
- nbdev_install_quarto
29
+ nbdev-install-quarto
30
30
  ```
31
31
 
32
32
  Then, after you make subsequent changes to `nbs/index.ipynb`, run the following from the repo's root directory to (re)build `README.md`:
33
33
 
34
34
  ```
35
- nbdev_readme
35
+ nbdev-readme
36
36
  ```
37
37
 
38
38
  ## Did you find a bug?
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fasthtml
3
- Version: 0.14.12
3
+ Version: 0.14.14
4
4
  Summary: The fastest way to create an HTML app
5
5
  Author-email: Jeremy Howard and contributors <github@jhoward.fastmail.fm>
6
- License: Apache-2.0
6
+ License-Expression: Apache-2.0
7
7
  Project-URL: Repository, https://github.com/AnswerDotAI/fasthtml
8
8
  Project-URL: Documentation, https://www.fastht.ml/docs/
9
9
  Keywords: nbdev,jupyter,notebook,python
@@ -4,5 +4,5 @@ Modules:
4
4
 
5
5
  - `fasthtml.core`: The `FastHTML` subclass of `Starlette`."""
6
6
 
7
- __version__ = "0.14.12"
7
+ __version__ = "0.14.14"
8
8
  from .core import *
@@ -5,12 +5,9 @@ d = { 'settings': { 'branch': 'main',
5
5
  'doc_host': 'https://www.fastht.ml',
6
6
  'git_url': 'https://github.com/AnswerDotAI/fasthtml',
7
7
  'lib_path': 'fasthtml'},
8
- 'syms': { 'fasthtml.authmw': {},
9
- 'fasthtml.basics': {},
10
- 'fasthtml.cli': { 'fasthtml.cli._run': ('api/cli.html#_run', 'fasthtml/cli.py'),
8
+ 'syms': { 'fasthtml.cli': { 'fasthtml.cli._run': ('api/cli.html#_run', 'fasthtml/cli.py'),
11
9
  'fasthtml.cli.railway_deploy': ('api/cli.html#railway_deploy', 'fasthtml/cli.py'),
12
10
  'fasthtml.cli.railway_link': ('api/cli.html#railway_link', 'fasthtml/cli.py')},
13
- 'fasthtml.common': {},
14
11
  'fasthtml.components': { 'fasthtml.components.FT.__add__': ('api/components.html#ft.__add__', 'fasthtml/components.py'),
15
12
  'fasthtml.components.FT.__radd__': ('api/components.html#ft.__radd__', 'fasthtml/components.py'),
16
13
  'fasthtml.components.FT.__str__': ('api/components.html#ft.__str__', 'fasthtml/components.py'),
@@ -130,6 +127,7 @@ d = { 'settings': { 'branch': 'main',
130
127
  'fasthtml.core._resp': ('api/core.html#_resp', 'fasthtml/core.py'),
131
128
  'fasthtml.core._route_pn': ('api/core.html#_route_pn', 'fasthtml/core.py'),
132
129
  'fasthtml.core._send_ws': ('api/core.html#_send_ws', 'fasthtml/core.py'),
130
+ 'fasthtml.core._spawn': ('api/core.html#_spawn', 'fasthtml/core.py'),
133
131
  'fasthtml.core._to_htmx_header': ('api/core.html#_to_htmx_header', 'fasthtml/core.py'),
134
132
  'fasthtml.core._to_xml': ('api/core.html#_to_xml', 'fasthtml/core.py'),
135
133
  'fasthtml.core._url_for': ('api/core.html#_url_for', 'fasthtml/core.py'),
@@ -168,8 +166,6 @@ d = { 'settings': { 'branch': 'main',
168
166
  'fasthtml.core.until_disconnect': ('api/core.html#until_disconnect', 'fasthtml/core.py'),
169
167
  'fasthtml.core.uri': ('api/core.html#uri', 'fasthtml/core.py'),
170
168
  'fasthtml.core.vurl': ('api/core.html#vurl', 'fasthtml/core.py')},
171
- 'fasthtml.fastapp': {},
172
- 'fasthtml.ft': {},
173
169
  'fasthtml.js': { 'fasthtml.js.HighlightJS': ('api/js.html#highlightjs', 'fasthtml/js.py'),
174
170
  'fasthtml.js.KatexMarkdownJS': ('api/js.html#katexmarkdownjs', 'fasthtml/js.py'),
175
171
  'fasthtml.js.MarkdownJS': ('api/js.html#markdownjs', 'fasthtml/js.py'),
@@ -195,7 +191,6 @@ d = { 'settings': { 'branch': 'main',
195
191
  'fasthtml.jupyter.render_ft': ('api/jupyter.html#render_ft', 'fasthtml/jupyter.py'),
196
192
  'fasthtml.jupyter.show': ('api/jupyter.html#show', 'fasthtml/jupyter.py'),
197
193
  'fasthtml.jupyter.ws_client': ('api/jupyter.html#ws_client', 'fasthtml/jupyter.py')},
198
- 'fasthtml.live_reload': {},
199
194
  'fasthtml.oauth': { 'fasthtml.oauth.AppleAppClient': ('api/oauth.html#appleappclient', 'fasthtml/oauth.py'),
200
195
  'fasthtml.oauth.AppleAppClient.__init__': ('api/oauth.html#appleappclient.__init__', 'fasthtml/oauth.py'),
201
196
  'fasthtml.oauth.AppleAppClient.client_secret': ( 'api/oauth.html#appleappclient.client_secret',
@@ -264,7 +259,6 @@ d = { 'settings': { 'branch': 'main',
264
259
  'fasthtml.pico.PicoBusy': ('api/pico.html#picobusy', 'fasthtml/pico.py'),
265
260
  'fasthtml.pico.Search': ('api/pico.html#search', 'fasthtml/pico.py'),
266
261
  'fasthtml.pico.set_pico_cls': ('api/pico.html#set_pico_cls', 'fasthtml/pico.py')},
267
- 'fasthtml.starlette': {},
268
262
  'fasthtml.stripe_otp': { 'fasthtml.stripe_otp.Payment': ('explains/stripe.html#payment', 'fasthtml/stripe_otp.py'),
269
263
  'fasthtml.stripe_otp._search_app': ('explains/stripe.html#_search_app', 'fasthtml/stripe_otp.py'),
270
264
  'fasthtml.stripe_otp.account_management': ( 'explains/stripe.html#account_management',
@@ -304,7 +298,6 @@ d = { 'settings': { 'branch': 'main',
304
298
  'fasthtml.svg.Text': ('api/svg.html#text', 'fasthtml/svg.py'),
305
299
  'fasthtml.svg.ft_svg': ('api/svg.html#ft_svg', 'fasthtml/svg.py'),
306
300
  'fasthtml.svg.transformd': ('api/svg.html#transformd', 'fasthtml/svg.py')},
307
- 'fasthtml.toaster': {},
308
301
  'fasthtml.xtend': { 'fasthtml.xtend.A': ('api/xtend.html#a', 'fasthtml/xtend.py'),
309
302
  'fasthtml.xtend.AX': ('api/xtend.html#ax', 'fasthtml/xtend.py'),
310
303
  'fasthtml.xtend.AnyNow': ('api/xtend.html#anynow', 'fasthtml/xtend.py'),
@@ -18,7 +18,7 @@ The decorated function doubles as a URL builder, so links never need hardcoded p
18
18
 
19
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
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:
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. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `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
22
 
23
23
  req, ws the Request / WebSocket connection
24
24
  sess session dict
@@ -55,7 +55,7 @@ __all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'DEF_MAXPART', 'htmx_ex
55
55
  'MiddlewareBase', 'FtResponse', 'unqid']
56
56
 
57
57
  # %% ../nbs/api/00_core.ipynb #23503b9e
58
- 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
59
59
  from uuid import uuid5, NAMESPACE_URL
60
60
 
61
61
  from fastcore.utils import *
@@ -80,6 +80,7 @@ from base64 import b64encode,b64decode
80
80
  from email.utils import format_datetime
81
81
 
82
82
  from .starlette import *
83
+ from python_multipart.exceptions import MultipartParseError
83
84
 
84
85
  # %% ../nbs/api/00_core.ipynb #19d3f2a7
85
86
  def _params(f): return signature_ex(f, True).parameters
@@ -169,7 +170,7 @@ class HttpHeader:
169
170
  def _to_htmx_header(s): return 'HX-' + s.replace('_', '-').title()
170
171
 
171
172
  htmx_resps = dict(location=None, push_url=None, redirect=None, refresh=None, replace_url=None,
172
- reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)
173
+ reswap=None, retarget=None, reselect=None, trigger=None, trigger_after_settle=None, trigger_after_swap=None)
173
174
 
174
175
  # %% ../nbs/api/00_core.ipynb #f6a2e62e
175
176
  @use_kwargs_dict(**htmx_resps)
@@ -189,7 +190,8 @@ def _is_body(anno): return issubclass(anno, (dict,ns)) or hasattr(anno,'__from_r
189
190
 
190
191
  # %% ../nbs/api/00_core.ipynb #ffdde66f
191
192
  def _formitem(form, k):
192
- "Return single item `k` from `form` if len 1, otherwise return list"
193
+ "Return item `k`, accepting hyphens for underscores and unwrapping single-item form lists"
194
+ if k not in form: k = {o.replace('-', '_'):o for o in form}.get(k, k)
193
195
  if isinstance(form, dict): return form.get(k)
194
196
  o = form.getlist(k)
195
197
  return o[0] if len(o) == 1 else o if o else None
@@ -205,16 +207,21 @@ DEF_MAXPART = 100*1024*1024
205
207
 
206
208
  # %% ../nbs/api/00_core.ipynb #42c9cea0
207
209
  async def parse_form(req: Request) -> FormData:
208
- "Starlette errors on empty multipart/json forms, so this checks for that situation"
210
+ "Starlette errors on empty multipart/json forms and 500s on malformed multipart, so this handles those situations"
209
211
  ctype = req.headers.get("Content-Type", "")
210
212
  maxpart = getattr(req, "max_part_size", DEF_MAXPART)
211
213
  if ctype.startswith("multipart/form-data"):
212
214
  try: boundary = ctype.split("boundary=")[1].strip()
213
215
  except IndexError: raise HTTPException(400, "Invalid form-data: no boundary")
214
216
  if int(req.headers.get("Content-Length", "0")) <= len(boundary) + 6: return FormData()
215
- return await req.form(max_part_size=maxpart)
217
+ try: return await req.form(max_part_size=maxpart)
218
+ except MultipartParseError as e: raise HTTPException(400, f"Invalid form-data: {e}")
216
219
  body = await req.body() # Cache body for non-multipart request types
217
- if ctype == 'application/json': return await req.json() if body else {}
220
+ if ctype == 'application/json':
221
+ try: res = await req.json() if body else {}
222
+ except ValueError: raise HTTPException(400, "Invalid JSON body")
223
+ if not isinstance(res, dict): raise HTTPException(400, "JSON body must be an object")
224
+ return res
218
225
  return await req.form(max_part_size=maxpart)
219
226
 
220
227
  # %% ../nbs/api/00_core.ipynb #0caedd04
@@ -229,6 +236,7 @@ async def _from_body(conn, p, data):
229
236
  kwargs = await _find_ps(conn, data, conn.headers, ps)
230
237
  return await maybe_await(ctor(**kwargs))
231
238
  d = _annotations(anno)
239
+ if d: data = {k.replace('-', '_'):v for k,v in data.items()} | data
232
240
  cargs = {k: _form_arg(k, v, d) for k, v in data.items() if not d or k in d}
233
241
  return anno(**cargs)
234
242
 
@@ -286,10 +294,9 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter):
286
294
  res = conn.path_params.get(arg, None)
287
295
  if res in (empty,None): res = conn.cookies.get(arg, None)
288
296
  if res in (empty,None): res = hdrs.get(snake2hyphens(arg), None)
289
- if res in (empty,None): res = conn.query_params.getlist(arg)
290
- if res==[]: res = None
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`
297
+ if res in (empty,None): res = _formitem(conn.query_params, arg)
298
+ if res in (empty,None): res = _formitem(data, arg)
299
+ if res in (empty,None) and isinstance(vals := data.get('values'), dict): res = _formitem(vals, arg) # htmx v4 nests form data under `values`
293
300
  if res in (empty,None):
294
301
  if p.default is empty:
295
302
  if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}")
@@ -544,6 +551,7 @@ async def _wrap_call(f, req, params):
544
551
  return await _handle(f, **wreq)
545
552
 
546
553
  # %% ../nbs/api/00_core.ipynb #b0d1cbbf
554
+ # chkstyle: skip
547
555
  htmx_exts = {
548
556
  "morph": "https://cdn.jsdelivr.net/npm/idiomorph@0.7.3/dist/idiomorph-ext.min.js",
549
557
  "head-support": "https://cdn.jsdelivr.net/npm/htmx-ext-head-support@2.0.4/head-support.js",
@@ -555,16 +563,16 @@ htmx_exts = {
555
563
  "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js",
556
564
  "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js",
557
565
  "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js",
558
- "ws4": "https://unpkg.com/htmx.org@4.0.0-beta6/dist/ext/hx-ws.js",
566
+ "ws4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-ws.min.js",
559
567
  "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"
568
+ "sse4": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js",
569
+ "live": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js",
570
+ "multipart": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-multipart.min.js"
563
571
  }
564
572
 
565
573
  # %% ../nbs/api/00_core.ipynb #60cb52ea
566
574
  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")
575
+ htmx4src = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js")
568
576
  fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js")
569
577
  surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js")
570
578
  scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js")
@@ -618,13 +626,11 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True):
618
626
  meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"}))
619
627
  hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs
620
628
  # TODO: Check if fhjsscr works with htmx4
621
- elif htmx:
622
- hdrs = [htmxsrc,fhjsscr] + hdrs
629
+ elif htmx: hdrs = [htmxsrc,fhjsscr] + hdrs
623
630
  return [charset, viewport] + hdrs
624
631
 
625
632
  # %% ../nbs/api/00_core.ipynb #2c5285ae
626
- cors_allow = Middleware(CORSMiddleware, allow_credentials=True,
627
- allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
633
+ cors_allow = Middleware(CORSMiddleware, allow_credentials=True, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
628
634
 
629
635
  iframe_scr = Script(NotStr("""
630
636
  function sendmsg() {
@@ -674,23 +680,21 @@ class Lifespan:
674
680
  else: yield
675
681
  for f in self.shutdown: await _handle(f)
676
682
 
677
- def on_event(self, event_type):
678
- return lambda f: (getattr(self, event_type)).append(f)
683
+ def on_event(self, event_type): return lambda f: (getattr(self, event_type)).append(f)
679
684
 
680
685
  # %% ../nbs/api/00_core.ipynb #3327a1e9
681
686
  class FastHTML(Starlette):
682
687
  "An HTML-first Starlette app: handler params filled from the request, FT returns rendered as pages or HTMX fragments"
683
688
  def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None,
684
- on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,
685
- before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,
686
- secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',
687
- same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',
688
- body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw):
689
+ on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,
690
+ before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,
691
+ secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',
692
+ same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',
693
+ body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, max_part_size=DEF_MAXPART, **bodykw):
689
694
  middleware,before,after = map(_list, (middleware,before,after))
690
695
  self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname
691
696
  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]
697
+ if htmx4 and exts: exts = ['ws4' if e in ('ws', 'ws4') else e for e in exts]
694
698
  exts = {k:htmx_exts[k] for k in exts}
695
699
  htmlkw = htmlkw or {}
696
700
  if default_hdrs: hdrs = def_hdrs(htmx, htmx4, surreal=surreal) + hdrs
@@ -705,9 +709,8 @@ class FastHTML(Starlette):
705
709
  self.body_wrap,self.before,self.after,self.htmlkw,self.bodykw,self.max_part_size = body_wrap,before,after,htmlkw,bodykw,max_part_size
706
710
  self.secret_key = get_key(secret_key, key_fname)
707
711
  if sess_cls:
708
- sess = Middleware(sess_cls, secret_key=self.secret_key,session_cookie=session_cookie,
709
- max_age=max_age, path=sess_path, same_site=same_site,
710
- https_only=sess_https_only, domain=sess_domain)
712
+ sess = Middleware(sess_cls, secret_key=self.secret_key, session_cookie=session_cookie, max_age=max_age, path=sess_path,
713
+ same_site=same_site, https_only=sess_https_only, domain=sess_domain)
711
714
  middleware.append(sess)
712
715
  exception_handlers = ifnone(exception_handlers, {})
713
716
  if 404 not in exception_handlers:
@@ -742,8 +745,8 @@ def add_route(self:FastHTML, route):
742
745
  "Add or replace a route in the FastHTML app"
743
746
  route.methods = [m.upper() for m in listify(route.methods)]
744
747
  self.router.routes = [r for r in self.router.routes if not
745
- (r.path==route.path and r.name == route.name and
746
- ((route.methods is None) or (set(r.methods) == set(route.methods))))]
748
+ (r.path==route.path and r.name == route.name and
749
+ ((route.methods is None) or (set(r.methods) == set(route.methods))))]
747
750
  self.router.routes.append(route)
748
751
  self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))
749
752
 
@@ -767,8 +770,7 @@ def _endp(self:FastHTML, f, body_wrap, before:Optional[Callable|tuple]=None):
767
770
  if not resp:
768
771
  if isinstance(b, Beforeware): bf,skip = b.f,b.skip
769
772
  else: bf,skip = b,[]
770
- if not any(re.fullmatch(r, req.url.path) for r in skip):
771
- resp = await _wrap_call(bf, req, _params(bf))
773
+ if not any(re.fullmatch(r, req.url.path) for r in skip): resp = await _wrap_call(bf, req, _params(bf))
772
774
  for b in listify(before):
773
775
  if not resp: resp = await _wrap_call(b, req, _params(b))
774
776
  req.body_wrap = body_wrap
@@ -879,12 +881,13 @@ def set_lifespan(self:FastHTML, value):
879
881
 
880
882
  # %% ../nbs/api/00_core.ipynb #3a348474
881
883
  def serve(
882
- appname=None, # Name of the module
883
- app='app', # App instance to be served
884
- host='0.0.0.0', # If host is 0.0.0.0 will convert to localhost
885
- port=None, # If port is None it will default to 5001 or the PORT environment variable
886
- reload=True, # Default is to reload the app upon code changes
887
- **kwargs
884
+ appname=None, # Name of the module
885
+ app='app', # App instance to be served
886
+ host='0.0.0.0', # If host is 0.0.0.0 will convert to localhost
887
+ port=None, # If port is None it will default to 5001 or the PORT environment variable
888
+ reload=True, # Default is to reload the app upon code changes
889
+ procs=None, # Processes to run on consecutive ports from `port`; default 1 or the PROCS environment variable
890
+ **kwargs
888
891
  ):
889
892
  "Run the app in an async server, with live reload set as the default."
890
893
  from uvicorn import run
@@ -895,10 +898,20 @@ def serve(
895
898
  elif back and back.f_globals.get('__name__')=='__main__': appname = inspect.getmodule(bk).__name__
896
899
  if appname:
897
900
  if not port: port=int(os.getenv("PORT", default=5001))
901
+ if not procs: procs=int(os.getenv("PROCS", default=1))
902
+ if procs>1 and 'FH_PROCNUM' not in os.environ: return _spawn(procs)
903
+ port += int(os.getenv('FH_PROCNUM', 0))
898
904
  link = f'http://{"localhost" if host=="0.0.0.0" else host}:{port}'
899
905
  print('Link: '+ S.light_red.bold(link))
900
906
  run(f'{appname}:{app}', host=host, port=port, reload=reload, **kwargs)
901
907
 
908
+ # %% ../nbs/api/00_core.ipynb #91b08e9a
909
+ def _spawn(n):
910
+ "Run this script `n` times over, numbering each copy in `FH_PROCNUM`, and wait for them all"
911
+ ps = [subprocess.Popen([sys.executable, *sys.argv], env={**os.environ, 'FH_PROCNUM':str(i)}) for i in range(n)]
912
+ signal.signal(signal.SIGTERM, lambda *a: [p.terminate() for p in ps])
913
+ for p in ps: p.wait()
914
+
902
915
  # %% ../nbs/api/00_core.ipynb #c5220a0e
903
916
  async def _wait_disconnect(req):
904
917
  while (await req.receive())["type"] != "http.disconnect": pass
@@ -1001,8 +1014,7 @@ def cookie(key: str, value="", max_age=None, expires=None, path="/", domain=None
1001
1014
  cookie = cookies.SimpleCookie()
1002
1015
  cookie[key] = value
1003
1016
  if max_age is not None: cookie[key]["max-age"] = max_age
1004
- if expires is not None:
1005
- cookie[key]["expires"] = format_datetime(expires, usegmt=True) if isinstance(expires, datetime) else expires
1017
+ if expires is not None: cookie[key]["expires"] = format_datetime(expires, usegmt=True) if isinstance(expires, datetime) else expires
1006
1018
  if path is not None: cookie[key]["path"] = path
1007
1019
  if domain is not None: cookie[key]["domain"] = domain
1008
1020
  if secure: cookie[key]["secure"] = True
@@ -1159,8 +1171,7 @@ def devtools_json(self:FastHTML, path=None, uuid=None):
1159
1171
  if not path: path = Path().absolute()
1160
1172
  if not uuid: uuid = str(uuid5(NAMESPACE_URL, str(path)))
1161
1173
  @self.route(devtools_loc)
1162
- def devtools():
1163
- return dict(workspace=dict(root=str(path), uuid=uuid))
1174
+ def devtools(): return dict(workspace=dict(root=str(path), uuid=uuid))
1164
1175
 
1165
1176
  # %% ../nbs/api/00_core.ipynb #e27908c0
1166
1177
  @patch
@@ -1192,8 +1203,7 @@ def get_testclient(self:FastHTML, **kw):
1192
1203
  "A Starlette TestClient with a `session` property"
1193
1204
  @property
1194
1205
  def session(self):
1195
- cookie = next((c.value for c in reversed(list(self.cookies.jar))
1196
- if c.name == self.app.session_cookie), None)
1206
+ cookie = next((c.value for c in reversed(list(self.cookies.jar)) if c.name == self.app.session_cookie), None)
1197
1207
  return self.app.decode_session(cookie)
1198
1208
 
1199
1209
  return FastHTMLTestClient(self, cookies=self.get_client(**kw).cookies)
@@ -249,7 +249,10 @@ class OAuth:
249
249
  return RedirectResponse(self.error_path, status_code=303)
250
250
  scheme = 'http' if url_match(req,self.http_patterns) or not self.https else 'https'
251
251
  base_url = f"{scheme}://{get_host(req)}"
252
- info = AttrDictDefault(await cli.retr_info_async(code, base_url+redir_path))
252
+ try: info = AttrDictDefault(await cli.retr_info_async(code, base_url+redir_path))
253
+ except httpx2.HTTPStatusError as e:
254
+ session['oauth_error'] = e.response.text
255
+ return RedirectResponse(self.error_path, status_code=303)
253
256
  ident = info.get(self.cli.id_key)
254
257
  if not ident: return self.redir_login(session)
255
258
  res = await _arun(self.get_auth(info, ident, session, state))
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["setuptools>=64"]
2
+ requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "The fastest way to create an HTML app"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
- license = {text = "Apache-2.0"}
11
+ license = "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"]
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fasthtml
3
- Version: 0.14.12
3
+ Version: 0.14.14
4
4
  Summary: The fastest way to create an HTML app
5
5
  Author-email: Jeremy Howard and contributors <github@jhoward.fastmail.fm>
6
- License: Apache-2.0
6
+ License-Expression: Apache-2.0
7
7
  Project-URL: Repository, https://github.com/AnswerDotAI/fasthtml
8
8
  Project-URL: Documentation, https://www.fastht.ml/docs/
9
9
  Keywords: nbdev,jupyter,notebook,python