pyweb-stack 0.4.2__tar.gz → 0.4.3__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 (116) hide show
  1. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/PKG-INFO +7 -3
  2. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/README.md +5 -1
  3. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyproject.toml +2 -2
  4. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/ai/guide.md +1 -1
  5. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/asgi.py +6 -3
  6. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/auth.py +19 -5
  7. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/cache.py +22 -1
  8. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/cli/__init__.py +9 -3
  9. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/lower.py +33 -2
  10. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/db/__init__.py +11 -2
  11. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/dts.py +32 -12
  12. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/hosting.py +88 -16
  13. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/jobs.py +35 -13
  14. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/lsp.py +1 -1
  15. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/markdown.py +1 -1
  16. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/models.py +0 -1
  17. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/packages.py +2 -4
  18. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/realtime.py +32 -7
  19. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/rpc.py +20 -5
  20. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/runtime/browser/runtime.js +2 -0
  21. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/runtime/server/__init__.py +50 -7
  22. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/serve.py +15 -23
  23. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/uploads.py +5 -1
  24. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb_stack.egg-info/PKG-INFO +7 -3
  25. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb_stack.egg-info/SOURCES.txt +1 -0
  26. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_auth_security.py +20 -0
  27. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_db_production.py +29 -0
  28. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_deploy_upload_render.py +2 -0
  29. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_forms_security_obs.py +13 -1
  30. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_jobs_cache_realtime.py +44 -2
  31. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_parser_v1.py +38 -0
  32. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_realtime_transport.py +22 -0
  33. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_rpc_production.py +45 -0
  34. pyweb_stack-0.4.3/tests/test_static_serving.py +120 -0
  35. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/LICENSE +0 -0
  36. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/__init__.py +0 -0
  37. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/app.py +0 -0
  38. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/app_loader.py +0 -0
  39. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/bench.py +0 -0
  40. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/browser.py +0 -0
  41. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/build.py +0 -0
  42. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/cli/__main__.py +0 -0
  43. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/__init__.py +0 -0
  44. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/ast.py +0 -0
  45. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/codegen/__init__.py +0 -0
  46. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/codegen/ir.py +0 -0
  47. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/errors.py +0 -0
  48. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/parser.py +0 -0
  49. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/pipeline.py +0 -0
  50. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/pyjs.py +0 -0
  51. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/compiler/rpc.py +0 -0
  52. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/context.py +0 -0
  53. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/css.py +0 -0
  54. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/db/migrate.py +0 -0
  55. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/decorators.py +0 -0
  56. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/deploy.py +0 -0
  57. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/forms.py +0 -0
  58. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/livedata.py +0 -0
  59. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/livetable.py +0 -0
  60. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/mcp.py +0 -0
  61. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/observability.py +0 -0
  62. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/platform.py +0 -0
  63. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/plugins.py +0 -0
  64. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/py.typed +0 -0
  65. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/runtime/browser/markdown.js +0 -0
  66. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/security.py +0 -0
  67. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/ssr.py +0 -0
  68. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/sync.py +0 -0
  69. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/ai-chat.pyweb +0 -0
  70. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/app.css +0 -0
  71. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/auth.pyweb +0 -0
  72. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/blog.pyweb +0 -0
  73. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/chat.pyweb +0 -0
  74. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/counter.pyweb +0 -0
  75. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/templates/todo.pyweb +0 -0
  76. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb/testing.py +0 -0
  77. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb_stack.egg-info/dependency_links.txt +0 -0
  78. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb_stack.egg-info/entry_points.txt +0 -0
  79. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb_stack.egg-info/requires.txt +0 -0
  80. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/pyweb_stack.egg-info/top_level.txt +0 -0
  81. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/setup.cfg +0 -0
  82. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_auth.py +0 -0
  83. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_auth_contract.py +0 -0
  84. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_auth_v1.py +0 -0
  85. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_backplane.py +0 -0
  86. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_browser_build.py +0 -0
  87. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_budget.py +0 -0
  88. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_cli_deploy.py +0 -0
  89. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_codegen.py +0 -0
  90. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_compiler.py +0 -0
  91. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_docs.py +0 -0
  92. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_dts.py +0 -0
  93. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_e2e.py +0 -0
  94. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_examples.py +0 -0
  95. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_livedata.py +0 -0
  96. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_lsp.py +0 -0
  97. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_markdown.py +0 -0
  98. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_mcp.py +0 -0
  99. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_models_db.py +0 -0
  100. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_multifile.py +0 -0
  101. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_multipage.py +0 -0
  102. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_observability.py +0 -0
  103. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_packages.py +0 -0
  104. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_parser.py +0 -0
  105. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_plugins_platform_bench.py +0 -0
  106. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_production_gaps.py +0 -0
  107. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_pyjs_semantics.py +0 -0
  108. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_reactivity.py +0 -0
  109. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_reference_apps.py +0 -0
  110. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_rpc_placement.py +0 -0
  111. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_runtime_signals.py +0 -0
  112. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_server_db.py +0 -0
  113. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_static_hashing.py +0 -0
  114. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_streaming.py +0 -0
  115. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_sync_live.py +0 -0
  116. {pyweb_stack-0.4.2 → pyweb_stack-0.4.3}/tests/test_website.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyweb-stack
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Full-stack web apps in one Python file: server-rendered pages, reactive browser UI, typed RPC.
5
5
  Author-email: MaanavKrishna <67054795+MaanavKrishna@users.noreply.github.com>
6
6
  Maintainer-email: MaanavKrishna <67054795+MaanavKrishna@users.noreply.github.com>
7
7
  License: MIT
8
8
  Project-URL: Homepage, https://maanavkrishna.github.io/PyWeb/
9
- Project-URL: Documentation, https://maanavkrishna.github.io/PyWeb/guide.html
9
+ Project-URL: Documentation, https://maanavkrishna.github.io/PyWeb/introduction.html
10
10
  Project-URL: Source, https://github.com/MaanavKrishna/PyWeb
11
11
  Project-URL: Changelog, https://github.com/MaanavKrishna/PyWeb/blob/main/CHANGELOG.md
12
12
  Project-URL: Issues, https://github.com/MaanavKrishna/PyWeb/issues
@@ -72,6 +72,10 @@ your server, live data and streaming AI, with no JavaScript toolchain.
72
72
  **[Examples](#examples)** ·
73
73
  **[Changelog](CHANGELOG.md)**
74
74
 
75
+ <br>
76
+
77
+ <img src="https://raw.githubusercontent.com/MaanavKrishna/PyWeb/main/docs/images/demo.gif" alt="A guestbook app written in one .pyweb file: the submit handler runs in the browser and sign() runs on the server" width="860">
78
+
75
79
  </div>
76
80
 
77
81
  ```pyweb
@@ -380,7 +384,7 @@ Upgrade with `pip install -U pyweb-stack`.
380
384
 
381
385
  | Version | Highlights |
382
386
  |---|---|
383
- | 0.4 | npm packages without Node, layouts and client-side navigation, streaming server functions and `<Markdown>` for AI apps, live queries, page head tags, `.pyweb` error pages; 0.4.1: richer MCP tools; 0.4.2: VS Code run, new-app and MCP commands |
387
+ | 0.4 | npm packages without Node, layouts and client-side navigation, streaming server functions and `<Markdown>` for AI apps, live queries, page head tags, `.pyweb` error pages; 0.4.1: richer MCP tools; 0.4.2: VS Code run, new-app and MCP commands; 0.4.3: fixes, security hardening, gzip and caching |
384
388
  | 0.3 | Hydration, live updates (SSE), multi-file apps, language server + VS Code extension, browser playground, faster rendering, screenshot/test MCP tools |
385
389
  | 0.2 | MCP server for AI assistants, AI guide, project templates, `AGENTS.md`/`CLAUDE.md`, `llms.txt` |
386
390
  | 0.1 | First public release: compiler, reactive runtime, server rendering, typed RPC, sessions, databases, CLI |
@@ -18,6 +18,10 @@ your server, live data and streaming AI, with no JavaScript toolchain.
18
18
  **[Examples](#examples)** ·
19
19
  **[Changelog](CHANGELOG.md)**
20
20
 
21
+ <br>
22
+
23
+ <img src="https://raw.githubusercontent.com/MaanavKrishna/PyWeb/main/docs/images/demo.gif" alt="A guestbook app written in one .pyweb file: the submit handler runs in the browser and sign() runs on the server" width="860">
24
+
21
25
  </div>
22
26
 
23
27
  ```pyweb
@@ -326,7 +330,7 @@ Upgrade with `pip install -U pyweb-stack`.
326
330
 
327
331
  | Version | Highlights |
328
332
  |---|---|
329
- | 0.4 | npm packages without Node, layouts and client-side navigation, streaming server functions and `<Markdown>` for AI apps, live queries, page head tags, `.pyweb` error pages; 0.4.1: richer MCP tools; 0.4.2: VS Code run, new-app and MCP commands |
333
+ | 0.4 | npm packages without Node, layouts and client-side navigation, streaming server functions and `<Markdown>` for AI apps, live queries, page head tags, `.pyweb` error pages; 0.4.1: richer MCP tools; 0.4.2: VS Code run, new-app and MCP commands; 0.4.3: fixes, security hardening, gzip and caching |
330
334
  | 0.3 | Hydration, live updates (SSE), multi-file apps, language server + VS Code extension, browser playground, faster rendering, screenshot/test MCP tools |
331
335
  | 0.2 | MCP server for AI assistants, AI guide, project templates, `AGENTS.md`/`CLAUDE.md`, `llms.txt` |
332
336
  | 0.1 | First public release: compiler, reactive runtime, server rendering, typed RPC, sessions, databases, CLI |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyweb-stack"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Full-stack web apps in one Python file: server-rendered pages, reactive browser UI, typed RPC."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -45,7 +45,7 @@ test = ["pytest>=8", "playwright>=1.45", "mcp>=1.2"]
45
45
 
46
46
  [project.urls]
47
47
  Homepage = "https://maanavkrishna.github.io/PyWeb/"
48
- Documentation = "https://maanavkrishna.github.io/PyWeb/guide.html"
48
+ Documentation = "https://maanavkrishna.github.io/PyWeb/introduction.html"
49
49
  Source = "https://github.com/MaanavKrishna/PyWeb"
50
50
  Changelog = "https://github.com/MaanavKrishna/PyWeb/blob/main/CHANGELOG.md"
51
51
  Issues = "https://github.com/MaanavKrishna/PyWeb/issues"
@@ -55,7 +55,7 @@ Static files go in `static/` next to `app.pyweb`, served at `/static/...`.
55
55
  | Code | Runs |
56
56
  |---|---|
57
57
  | imports, classes, DB connections, module objects | server only |
58
- | `NAME = <literal>` at module level | both (inlined into browser JS) |
58
+ | `NAME = <literal>` at module level | both (inlined into browser JS); server only if any code changes it (`.append`, `[k] =`, `global`) |
59
59
  | `@server` functions | server; browser calls become RPC |
60
60
  | undecorated module functions | server; compiled to JS only if browser code calls them |
61
61
  | page function body (top to the markup) | server, every request |
@@ -71,8 +71,10 @@ def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_
71
71
  break
72
72
  headers = {}
73
73
  for k, v in scope.get("headers", []):
74
- name = k.decode("latin-1").title()
75
- headers[name] = v.decode("latin-1")
74
+ name, value = k.decode("latin-1").title(), v.decode("latin-1")
75
+ if name in headers: # repeated (HTTP/2 sends cookies as several headers)
76
+ value = headers[name] + ("; " if name == "Cookie" else ", ") + value
77
+ headers[name] = value
76
78
  path = scope.get("path", "/")
77
79
  if scope.get("query_string"):
78
80
  path += "?" + scope["query_string"].decode("latin-1")
@@ -81,7 +83,8 @@ def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_
81
83
  b'{"error": {"code": "http_413", "message": "request body too large"}}'
82
84
  else:
83
85
  status, hdrs, body = await asyncio.to_thread(
84
- site.respond, scope.get("method", "GET"), path, headers, b"".join(chunks))
86
+ site.respond, scope.get("method", "GET"), path, headers, b"".join(chunks),
87
+ (scope.get("client") or [None])[0])
85
88
  raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in hdrs]
86
89
  if hasattr(body, "aiter"): # Server-Sent Events, streamed RPC results
87
90
  await send({"type": "http.response.start", "status": status, "headers": raw_headers})
@@ -205,7 +205,20 @@ def require_session(req, secret: str, max_age: int = 3600):
205
205
  session = session_from_request(req, secret, max_age)
206
206
  if session is not None and "sub" in session:
207
207
  return session, None
208
- return None, Response(302, "", {"Location": f"{LOGIN_URL}?next={req.path}"})
208
+ nxt = urllib.parse.quote(safe_next(req.path), safe="/")
209
+ return None, Response(302, "", {"Location": f"{LOGIN_URL}?next={nxt}"})
210
+
211
+
212
+ def safe_next(url, default="/"):
213
+ """``url`` if it's a path on this site, else ``default``.
214
+
215
+ Use it on a ``?next=`` value before redirecting there after login, so a
216
+ link like ``/login?next=//evil.example`` can't send people elsewhere.
217
+ """
218
+ url = str(url or "")
219
+ if not url.startswith("/") or url.startswith(("//", "/\\")) or any(c in url for c in "\r\n\t"):
220
+ return default
221
+ return url
209
222
 
210
223
 
211
224
  def login_response(user_id, secret: str, *, extra=None, next_url="/",
@@ -222,7 +235,7 @@ def login_response(user_id, secret: str, *, extra=None, next_url="/",
222
235
  flags = (f"HttpOnly; Path=/; Max-Age={int(max_age)}; SameSite=Lax"
223
236
  + ("; Secure" if secure else ""))
224
237
  return Response(302, "", {
225
- "Location": next_url,
238
+ "Location": safe_next(next_url),
226
239
  "Set-Cookie": f"{SESSION_COOKIE}={token}; {flags}",
227
240
  })
228
241
 
@@ -334,7 +347,8 @@ class OAuthClient:
334
347
  def issue_magic_token(secret: str, email: str, ttl: int = 900) -> str:
335
348
  payload = {"email": email, "exp": int(time.time()) + ttl}
336
349
  payload_b64 = _b64e(json.dumps(payload, separators=(",", ":")).encode())
337
- return f"{payload_b64}.{_sign(payload_b64, secret)}"
350
+ # Signed under its own prefix, so a session cookie can never pass as a login link (or back).
351
+ return f"{payload_b64}.{_sign('magic:' + payload_b64, secret)}"
338
352
 
339
353
 
340
354
  def verify_magic_token(secret: str, token: str, max_age: int = 900):
@@ -342,13 +356,13 @@ def verify_magic_token(secret: str, token: str, max_age: int = 900):
342
356
  payload_b64, sig = token.rsplit(".", 1)
343
357
  except ValueError:
344
358
  return None
345
- if not hmac.compare_digest(_sign(payload_b64, secret), sig):
359
+ if not hmac.compare_digest(_sign("magic:" + payload_b64, secret), sig):
346
360
  return None
347
361
  try:
348
362
  payload = json.loads(_b64d(payload_b64))
349
363
  except (ValueError, base64.binascii.Error, json.JSONDecodeError):
350
364
  return None
351
- if time.time() > payload.get("exp", 0):
365
+ if not isinstance(payload, dict) or time.time() > payload.get("exp", 0):
352
366
  return None
353
367
  return payload.get("email")
354
368
 
@@ -32,6 +32,24 @@ class MemoryCache(Cache):
32
32
  self._tags: dict[str, set] = {}
33
33
  self._lock = threading.Lock()
34
34
  self._now = time_fn or time.monotonic
35
+ self._sets = 0
36
+
37
+ def _drop(self, key):
38
+ """Remove ``key`` and its tag entries. Hold the lock."""
39
+ item = self._store.pop(key, None)
40
+ if item is not None:
41
+ for t in item[2]:
42
+ keys = self._tags.get(t)
43
+ if keys is not None:
44
+ keys.discard(key)
45
+ if not keys:
46
+ del self._tags[t]
47
+
48
+ def _sweep(self):
49
+ """Forget expired entries nobody asked for again. Hold the lock."""
50
+ now = self._now()
51
+ for key in [k for k, (_v, exp, _t) in self._store.items() if exp is not None and exp <= now]:
52
+ self._drop(key)
35
53
 
36
54
  def get(self, key, default=None):
37
55
  """Return ``(value, hit)`` tuple, preserving the prototype API."""
@@ -41,7 +59,7 @@ class MemoryCache(Cache):
41
59
  return None, False
42
60
  value, expires, _tags = item
43
61
  if expires is not None and expires <= self._now():
44
- del self._store[key]
62
+ self._drop(key)
45
63
  return None, False
46
64
  return value, True
47
65
 
@@ -55,6 +73,9 @@ class MemoryCache(Cache):
55
73
  ttl = kw["minutes"] * 60
56
74
  expires = None if ttl is None else self._now() + ttl
57
75
  with self._lock:
76
+ self._sets += 1
77
+ if self._sets % 256 == 0:
78
+ self._sweep()
58
79
  old = self._store.get(key)
59
80
  if isinstance(old, tuple) and len(old) == 3:
60
81
  for t in old[2]:
@@ -156,7 +156,8 @@ def cmd_dev(args):
156
156
  if state.error is not None or state.site is None:
157
157
  html = _error_overlay(state.error, state.path).encode()
158
158
  return self._send(500, [("Content-Type", "text/html; charset=utf-8")], html)
159
- status, headers, raw = state.site.respond(method, self.path, dict(self.headers), body)
159
+ status, headers, raw = state.site.respond(method, self.path, dict(self.headers), body,
160
+ client=self.client_address[0])
160
161
  ctype = next((v for k, v in headers if k.lower() == "content-type"), "")
161
162
  if ctype.startswith("text/html") and isinstance(raw, bytes) and b"</body>" in raw:
162
163
  raw = raw.replace(b"</body>", DEV_RELOAD_JS.encode() + b"</body>", 1)
@@ -193,12 +194,17 @@ def _watch_and_reload(state, Site):
193
194
 
194
195
  app_dir = os.path.dirname(os.path.abspath(state.path))
195
196
 
197
+ skip = {"__pycache__", "node_modules", "dist", "build", "venv", "env", "site-packages", "vendor"}
198
+
196
199
  def snapshot():
197
200
  stamps = {}
198
201
  for root, dirs, files in os.walk(app_dir):
199
- dirs[:] = [d for d in dirs if not d.startswith((".", "__pycache__", "node_modules", "dist"))]
202
+ # Virtualenvs (any folder with a pyvenv.cfg) hold thousands of files that never matter here;
203
+ # npm packages are covered by pyweb.lock, which changes whenever they do.
204
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip
205
+ and not os.path.exists(os.path.join(root, d, "pyvenv.cfg"))]
200
206
  for fn in files:
201
- if fn.endswith((".pyweb", ".py", ".css", ".js")):
207
+ if fn.endswith((".pyweb", ".py", ".css", ".js")) or fn == "pyweb.lock":
202
208
  p = os.path.join(root, fn)
203
209
  try:
204
210
  stamps[p] = os.path.getmtime(p)
@@ -321,6 +321,11 @@ def scan_module(tree, ui_all, filename, resolve=None):
321
321
  except (ValueError, SyntaxError, TypeError):
322
322
  ctx.server_only[name] = "a module-level object created on the server"
323
323
  ctx.modconsts.pop(name, None)
324
+ for name in _mutated_globals(tree) & set(ctx.modconsts):
325
+ # e.g. `ITEMS = []` that an @server function appends to: a browser copy of the
326
+ # starting value would be wrong, so pages get its current value from the server.
327
+ ctx.modconsts.pop(name)
328
+ ctx.server_only[name] = "a module-level value that server code changes"
324
329
  taken = {i.name for i in pages + layouts} | set(ctx.components)
325
330
  if not any(p.error_status is None for p in pages):
326
331
  # Single-page fallback: the first function with markup is "/".
@@ -339,6 +344,32 @@ def scan_module(tree, ui_all, filename, resolve=None):
339
344
  return ctx, pages, components
340
345
 
341
346
 
347
+ _MUTATORS = {"append", "extend", "insert", "pop", "remove", "clear", "update", "setdefault", "popitem",
348
+ "add", "discard", "sort", "reverse", "difference_update", "intersection_update",
349
+ "symmetric_difference_update"}
350
+
351
+
352
+ def _mutated_globals(tree):
353
+ """Module-level names that some code changes after they're defined."""
354
+ out = set()
355
+ for node in ast.walk(tree):
356
+ if isinstance(node, ast.Global):
357
+ out.update(node.names)
358
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
359
+ and node.func.attr in _MUTATORS and isinstance(node.func.value, ast.Name):
360
+ out.add(node.func.value.id)
361
+ elif isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign, ast.Delete)):
362
+ targets = node.targets if isinstance(node, (ast.Assign, ast.Delete)) else [node.target]
363
+ for t in targets:
364
+ while isinstance(t, (ast.Subscript, ast.Attribute)):
365
+ t = t.value
366
+ if isinstance(t, ast.Name):
367
+ out.add(t.id)
368
+ if isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
369
+ out.add(node.target.id)
370
+ return out
371
+
372
+
342
373
  def _ui_of(node, ui_all):
343
374
  span = (node.lineno, getattr(node, "end_lineno", node.lineno))
344
375
  return [n for n in ui_all if span[0] <= getattr(n, "line", 0) <= span[1]]
@@ -846,7 +877,7 @@ class Emitter:
846
877
  lines.append(f" const {js} = $s[{key}];")
847
878
  else:
848
879
  lines.append(f" const {js} = {fallback};")
849
- for hname, hnode in info.handlers.items():
880
+ for hnode in info.handlers.values():
850
881
  lines.append(self.tr.function(hnode, scope, " "))
851
882
  for name in getattr(info, "live", ()):
852
883
  if name in info.refs:
@@ -925,7 +956,7 @@ class Emitter:
925
956
  return f"$dyn({_thunk(js)})"
926
957
  return f"$t($py.text({js}))"
927
958
  if t == "ControlFor":
928
- it_node, it_js = self._expr(n.iterable, n.line, scope)
959
+ _it_node, it_js = self._expr(n.iterable, n.line, scope)
929
960
  target = self._parse(n.target, n.line)
930
961
  inner = scope.child({name: VALUE for name in pyjs._target_names(target)})
931
962
  try:
@@ -386,6 +386,11 @@ class _PooledDB:
386
386
  return res
387
387
  except Exception as exc: # noqa: BLE001
388
388
  last = exc
389
+ if not owned:
390
+ # Inside transaction(): rolling back here would drop the
391
+ # transaction's earlier writes while later ones still commit.
392
+ # transaction() rolls back if the error escapes it.
393
+ raise
389
394
  try:
390
395
  conn.rollback()
391
396
  except Exception:
@@ -400,7 +405,10 @@ class _PooledDB:
400
405
 
401
406
  def stream(self, sql, params=(), *, chunksize=1000):
402
407
  """Yield ``Result`` pages without loading the full result set."""
403
- conn = self._acquire()
408
+ conn = self._current() # inside transaction(): use (and see) its connection
409
+ owned = conn is None
410
+ if owned:
411
+ conn = self._acquire()
404
412
  sql = self._adapt(sql, params)
405
413
  try:
406
414
  cur = conn.cursor() if hasattr(conn, "cursor") else None
@@ -420,7 +428,8 @@ class _PooledDB:
420
428
  except Exception:
421
429
  pass
422
430
  finally:
423
- self._pool.put(conn)
431
+ if owned:
432
+ self._pool.put(conn)
424
433
 
425
434
  @contextmanager
426
435
  def transaction(self):
@@ -34,33 +34,53 @@ def _ts_to_py(ts_type):
34
34
  return _TS_SCALARS.get(t, t)
35
35
 
36
36
 
37
+ def _members(body):
38
+ """The members of an object type, split on newlines, `;` and top-level `,`."""
39
+ import re
40
+ body = re.sub(r"//[^\n]*", "", body)
41
+ out, depth, cur = [], 0, ""
42
+ for ch in body:
43
+ if ch in "<([{":
44
+ depth += 1
45
+ elif ch in ">)]}":
46
+ depth -= 1
47
+ if depth == 0 and ch in "\n;,":
48
+ out.append(cur.strip())
49
+ cur = ""
50
+ else:
51
+ cur += ch
52
+ out.append(cur.strip())
53
+ return [m for m in out if m]
54
+
55
+
37
56
  def from_dts(dts: str):
38
57
  """Parse `interface`/`type` declarations into typed Python dataclasses."""
39
58
  import re
40
59
  out = ["from __future__ import annotations", "from dataclasses import dataclass",
41
60
  "from typing import Optional", ""]
42
- found = False
61
+ classes = set()
43
62
  for m in re.finditer(r"(?:interface|type)\s+(\w+)(?:\s*=\s*)?\s*\{([^}]*)\}", dts):
44
- found = True
45
63
  name, body = m.group(1), m.group(2)
64
+ classes.add(name)
46
65
  out.append("@dataclass")
47
66
  out.append(f"class {name}:")
48
- n_fields = 0
49
- for line in body.splitlines():
50
- line = line.split("//")[0].strip().rstrip(",;")
67
+ required, optional = [], []
68
+ for line in _members(body):
51
69
  fm = re.match(r"(?:readonly\s+)?(\w+)(\?)?:\s*(.+)", line)
52
70
  if fm:
53
71
  fname, opt, ftype = fm.group(1), fm.group(2), fm.group(3).strip()
54
72
  py_t = _ts_to_py(ftype)
55
- out.append(f" {fname}: {py_t} = None" if opt else f" {fname}: {py_t}")
56
- n_fields += 1
57
- if not n_fields:
58
- out.append(" pass")
73
+ # Fields with defaults must follow those without, or the dataclass won't import.
74
+ (optional if opt else required).append(
75
+ f" {fname}: {py_t if py_t.startswith('Optional[') else f'Optional[{py_t}]'} = None"
76
+ if opt else f" {fname}: {py_t}")
77
+ out.extend(required + optional or [" pass"])
59
78
  out.append("")
60
- for m in re.finditer(r"type\s+(\w+)\s*=\s*([^;]+);", dts):
61
- if m.group(1) not in dts:
79
+ # Plain aliases (`type Id = string | number;`); object types became classes above.
80
+ for m in re.finditer(r"\btype\s+(\w+)\s*=\s*([^;{]+);", dts):
81
+ if m.group(1) not in classes:
62
82
  out.append(f"{m.group(1)} = {_ts_to_py(m.group(2))}")
63
- return "\n".join(out) if found else "\n".join(out)
83
+ return "\n".join(out)
64
84
 
65
85
 
66
86
  @dataclass
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import json
13
13
  import mimetypes
14
14
  import os
15
+ import re
15
16
  import time
16
17
 
17
18
  from .runtime.server import Request, Server
@@ -35,6 +36,81 @@ def _ctype(path):
35
36
  return mimetypes.guess_type(path)[0] or "application/octet-stream"
36
37
 
37
38
 
39
+ # `name.<12 hex>.js|css|js.map` from `pyweb build --production`, and npm files under
40
+ # `vendor/<package>@<version>/`: their URL changes when their content does.
41
+ _VERSIONED = re.compile(r"(^|/)[^/]+\.[0-9a-f]{12}\.(js|css|js\.map)$|(^|/)vendor/(@[^/]+/)?[^/@]+@[^/]+/")
42
+ IMMUTABLE = "public, max-age=31536000, immutable"
43
+
44
+
45
+ def static_file(static_dirs, rel, *, query="", if_none_match=None, dev=False):
46
+ """``(status, headers, body)`` for a file under one of ``static_dirs``.
47
+
48
+ Content-hashed and versioned files (and ``?v=`` URLs) are cached for a
49
+ year. Everything else, such as your own ``static/app.css``, is revalidated
50
+ with an ETag, so a deploy shows up at once and an unchanged file costs a
51
+ 304 without a body.
52
+ """
53
+ rel = rel.split("?")[0]
54
+ for base in static_dirs:
55
+ base = os.path.realpath(base)
56
+ target = os.path.realpath(os.path.join(base, rel))
57
+ if target != base and not target.startswith(base + os.sep):
58
+ return 403, [("Content-Type", "text/plain")], b"forbidden"
59
+ try:
60
+ st = os.stat(target)
61
+ except OSError:
62
+ continue
63
+ if not os.path.isfile(target):
64
+ continue
65
+ etag = f'W/"{st.st_size:x}-{st.st_mtime_ns:x}"'
66
+ if dev:
67
+ cache = "no-cache"
68
+ elif _VERSIONED.search(rel) or re.search(r"(^|&)v=", query or ""):
69
+ cache = IMMUTABLE
70
+ else:
71
+ cache = "public, max-age=0, must-revalidate"
72
+ headers = [("Content-Type", _ctype(target)), ("Cache-Control", cache), ("ETag", etag)]
73
+ if if_none_match and etag in [t.strip() for t in if_none_match.split(",")]:
74
+ return 304, headers, b""
75
+ with open(target, "rb") as fh:
76
+ return 200, headers, fh.read()
77
+ return 404, [("Content-Type", "text/plain")], b"not found"
78
+
79
+
80
+ _COMPRESSIBLE = ("text/", "application/json", "application/javascript", "image/svg+xml", "application/xml")
81
+ _gz_cache: dict = {}
82
+
83
+
84
+ def gzip_response(headers, body, accept_encoding):
85
+ """Gzip ``body`` when the client accepts it and it's worth it; returns ``(headers, body)``.
86
+
87
+ ``headers`` is a list of ``(name, value)``. Static files (those with an ETag)
88
+ are compressed once and kept, so repeat requests cost a dictionary lookup.
89
+ """
90
+ if not isinstance(body, (bytes, bytearray)) or len(body) < 1024 or "gzip" not in (accept_encoding or ""):
91
+ return headers, body
92
+ names = {k.lower(): v for k, v in headers}
93
+ ctype = names.get("content-type", "")
94
+ if "content-encoding" in names or not ctype.startswith(_COMPRESSIBLE):
95
+ return headers, body
96
+ import gzip
97
+ etag = names.get("etag")
98
+ key = (etag, len(body)) if etag else None
99
+ packed = _gz_cache.get(key) if key else None
100
+ if packed is None:
101
+ packed = gzip.compress(bytes(body), 6, mtime=0)
102
+ if key:
103
+ if len(_gz_cache) > 512:
104
+ _gz_cache.clear()
105
+ _gz_cache[key] = packed
106
+ if len(packed) >= len(body):
107
+ return headers, body
108
+ vary = [v for k, v in headers if k.lower() == "vary"]
109
+ out = [(k, v) for k, v in headers if k.lower() not in ("content-length", "vary")]
110
+ out += [("Content-Encoding", "gzip"), ("Vary", ", ".join(vary + ["Accept-Encoding"]))]
111
+ return out, packed
112
+
113
+
38
114
  def is_stream(body):
39
115
  return not isinstance(body, (bytes, bytearray, str))
40
116
 
@@ -119,8 +195,9 @@ class Site:
119
195
  self.server = Server(app=app, debug=self.debug, max_body=self.max_body, **self.server_kwargs)
120
196
 
121
197
  # ------------------------------------------------------------ static
122
- def static(self, rel):
123
- rel = rel.split("?")[0]
198
+ def static(self, rel, *, query="", if_none_match=None):
199
+ rel, _, q = rel.partition("?")
200
+ query = query or q
124
201
  if self.source_mode:
125
202
  if rel in ("runtime.js", "markdown.js"):
126
203
  with open(os.path.join(os.path.dirname(RUNTIME_PATH), rel), "rb") as fh:
@@ -128,32 +205,24 @@ class Site:
128
205
  if rel in self.memory_js:
129
206
  return 200, [("Content-Type", _ctype(rel)), ("Cache-Control", "no-cache")], \
130
207
  self.memory_js[rel].encode()
131
- for base in self.static_dirs:
132
- base = os.path.realpath(base)
133
- target = os.path.realpath(os.path.join(base, rel))
134
- if target != base and not target.startswith(base + os.sep):
135
- return 403, [("Content-Type", "text/plain")], b"forbidden"
136
- if os.path.isfile(target):
137
- with open(target, "rb") as fh:
138
- body = fh.read()
139
- cache = "no-cache" if self.source_mode else "public, max-age=31536000, immutable"
140
- return 200, [("Content-Type", _ctype(target)), ("Cache-Control", cache)], body
141
- return 404, [("Content-Type", "text/plain")], b"not found"
208
+ return static_file(self.static_dirs, rel, query=query, if_none_match=if_none_match, dev=self.source_mode)
142
209
 
143
210
  # ----------------------------------------------------------- request
144
- def respond(self, method, path, headers, body=b""):
211
+ def respond(self, method, path, headers, body=b"", client=None):
145
212
  bare = path.split("?")[0]
146
213
  if bare in ("/healthz", "/health", "/readyz"):
147
214
  from . import __version__
148
215
  payload = {"ok": True, "version": __version__, "uptime_s": round(time.time() - self.started, 3)}
149
216
  out = (200, [("Content-Type", "application/json")], json.dumps(payload).encode())
150
217
  elif bare.startswith("/static/") and method in ("GET", "HEAD"):
151
- out = self.static(bare[len("/static/"):])
218
+ query = path.partition("?")[2]
219
+ inm = next((v for k, v in (headers or {}).items() if k.lower() == "if-none-match"), None)
220
+ out = self.static(bare[len("/static/"):], query=query, if_none_match=inm)
152
221
  elif len(body or b"") > self.max_body:
153
222
  out = (413, [("Content-Type", "application/json")],
154
223
  json.dumps({"error": {"code": "http_413", "message": "request body too large"}}).encode())
155
224
  else:
156
- resp = self.server.handle(Request(method, path, headers, body))
225
+ resp = self.server.handle(Request(method, path, headers, body, client=client))
157
226
  hdrs = []
158
227
  for k, v in resp.headers.items():
159
228
  for item in (v if isinstance(v, list) else [v]):
@@ -171,6 +240,9 @@ class Site:
171
240
  if ctype.startswith("text/html") and not self.debug and "content-security-policy" not in names:
172
241
  from .serve import csp_for
173
242
  hdrs.append(("Content-Security-Policy", csp_for(self.csp, raw) if isinstance(raw, bytes) else self.csp))
243
+ if not is_stream(raw) and status == 200:
244
+ ae = next((v for k, v in (headers or {}).items() if k.lower() == "accept-encoding"), "")
245
+ hdrs, raw = gzip_response(hdrs, raw, ae)
174
246
  if method == "HEAD":
175
247
  hdrs.append(("Content-Length", str(len(raw))))
176
248
  raw = b""