pyweb-stack 0.4.2__tar.gz → 0.4.4__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 (123) hide show
  1. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/PKG-INFO +7 -3
  2. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/README.md +5 -1
  3. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyproject.toml +2 -2
  4. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/ai/guide.md +7 -2
  5. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/asgi.py +8 -3
  6. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/auth.py +102 -9
  7. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/build.py +7 -0
  8. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/cache.py +44 -7
  9. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/cli/__init__.py +74 -11
  10. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/lower.py +33 -2
  11. pyweb_stack-0.4.4/pyweb/config.py +122 -0
  12. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/context.py +38 -6
  13. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/db/__init__.py +21 -2
  14. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/deploy.py +45 -2
  15. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/dts.py +32 -12
  16. pyweb_stack-0.4.4/pyweb/hosting.py +306 -0
  17. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/jobs.py +51 -13
  18. pyweb_stack-0.4.4/pyweb/keys.py +61 -0
  19. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/livedata.py +6 -5
  20. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/lsp.py +1 -1
  21. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/markdown.py +1 -1
  22. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/models.py +0 -1
  23. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/packages.py +37 -5
  24. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/realtime.py +51 -10
  25. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/rpc.py +73 -5
  26. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/runtime/browser/runtime.js +2 -0
  27. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/runtime/server/__init__.py +215 -26
  28. pyweb_stack-0.4.4/pyweb/runtime/server/argcheck.py +194 -0
  29. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/serve.py +91 -38
  30. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/uploads.py +5 -1
  31. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/PKG-INFO +7 -3
  32. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/SOURCES.txt +7 -0
  33. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth_security.py +20 -0
  34. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_cli_deploy.py +20 -0
  35. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_db_production.py +29 -0
  36. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_deploy_upload_render.py +2 -0
  37. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_forms_security_obs.py +13 -1
  38. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_jobs_cache_realtime.py +44 -2
  39. pyweb_stack-0.4.4/tests/test_keys_sessions.py +158 -0
  40. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_packages.py +32 -0
  41. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_parser_v1.py +38 -0
  42. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_production_gaps.py +2 -2
  43. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_realtime_transport.py +25 -1
  44. pyweb_stack-0.4.4/tests/test_request_hardening.py +197 -0
  45. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_rpc_production.py +45 -0
  46. pyweb_stack-0.4.4/tests/test_static_serving.py +120 -0
  47. pyweb_stack-0.4.4/tests/test_systems.py +158 -0
  48. pyweb_stack-0.4.2/pyweb/hosting.py +0 -177
  49. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/LICENSE +0 -0
  50. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/__init__.py +0 -0
  51. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/app.py +0 -0
  52. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/app_loader.py +0 -0
  53. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/bench.py +0 -0
  54. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/browser.py +0 -0
  55. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/cli/__main__.py +0 -0
  56. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/__init__.py +0 -0
  57. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/ast.py +0 -0
  58. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/__init__.py +0 -0
  59. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/ir.py +0 -0
  60. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/errors.py +0 -0
  61. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/parser.py +0 -0
  62. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/pipeline.py +0 -0
  63. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/pyjs.py +0 -0
  64. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/rpc.py +0 -0
  65. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/css.py +0 -0
  66. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/db/migrate.py +0 -0
  67. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/decorators.py +0 -0
  68. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/forms.py +0 -0
  69. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/livetable.py +0 -0
  70. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/mcp.py +0 -0
  71. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/observability.py +0 -0
  72. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/platform.py +0 -0
  73. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/plugins.py +0 -0
  74. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/py.typed +0 -0
  75. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/runtime/browser/markdown.js +0 -0
  76. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/security.py +0 -0
  77. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/ssr.py +0 -0
  78. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/sync.py +0 -0
  79. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/ai-chat.pyweb +0 -0
  80. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/app.css +0 -0
  81. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/auth.pyweb +0 -0
  82. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/blog.pyweb +0 -0
  83. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/chat.pyweb +0 -0
  84. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/counter.pyweb +0 -0
  85. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/todo.pyweb +0 -0
  86. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/testing.py +0 -0
  87. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/dependency_links.txt +0 -0
  88. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/entry_points.txt +0 -0
  89. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/requires.txt +0 -0
  90. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/top_level.txt +0 -0
  91. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/setup.cfg +0 -0
  92. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth.py +0 -0
  93. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth_contract.py +0 -0
  94. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth_v1.py +0 -0
  95. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_backplane.py +0 -0
  96. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_browser_build.py +0 -0
  97. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_budget.py +0 -0
  98. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_codegen.py +0 -0
  99. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_compiler.py +0 -0
  100. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_docs.py +0 -0
  101. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_dts.py +0 -0
  102. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_e2e.py +0 -0
  103. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_examples.py +0 -0
  104. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_livedata.py +0 -0
  105. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_lsp.py +0 -0
  106. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_markdown.py +0 -0
  107. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_mcp.py +0 -0
  108. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_models_db.py +0 -0
  109. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_multifile.py +0 -0
  110. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_multipage.py +0 -0
  111. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_observability.py +0 -0
  112. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_parser.py +0 -0
  113. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_plugins_platform_bench.py +0 -0
  114. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_pyjs_semantics.py +0 -0
  115. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_reactivity.py +0 -0
  116. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_reference_apps.py +0 -0
  117. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_rpc_placement.py +0 -0
  118. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_runtime_signals.py +0 -0
  119. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_server_db.py +0 -0
  120. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_static_hashing.py +0 -0
  121. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_streaming.py +0 -0
  122. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_sync_live.py +0 -0
  123. {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/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.4
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; 0.4.4: key rotation, session revocation, typed arguments, Redis-shared limits, `check --production` |
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; 0.4.4: key rotation, session revocation, typed arguments, Redis-shared limits, `check --production` |
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.4"
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 |
@@ -166,7 +166,12 @@ def Item(item_id: int):
166
166
  ```
167
167
 
168
168
  - `session.login(user_id, **claims)`, `session.user()`, `session.logout()`,
169
- `session.require("admin")`.
169
+ `session.require("admin")`. `pyweb.auth.revoke_user(user_id)` signs a user
170
+ out on every device (e.g. after a password change).
171
+ - Give every `@server` parameter a type hint (`str`, `int`, `list[int]`,
172
+ a dataclass or Model, `Literal[...]`): arguments are checked against
173
+ them, and wrong types or unknown arguments get a 422 before your code
174
+ runs. Still check permissions and business rules yourself.
170
175
  - `pyweb.auth.hash_password` / `verify_password` for passwords.
171
176
  - Database: `from pyweb.db import connect; db = connect("sqlite:///app.db")`;
172
177
  `db.execute("select ... where id = ?", (x,)).dicts()`; always use `?`
@@ -52,6 +52,8 @@ def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_
52
52
  if msg["type"] == "lifespan.startup":
53
53
  await send({"type": "lifespan.startup.complete"})
54
54
  elif msg["type"] == "lifespan.shutdown":
55
+ from .hosting import shutdown
56
+ await asyncio.to_thread(shutdown)
55
57
  await send({"type": "lifespan.shutdown.complete"})
56
58
  return
57
59
  if scope["type"] != "http":
@@ -71,8 +73,10 @@ def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_
71
73
  break
72
74
  headers = {}
73
75
  for k, v in scope.get("headers", []):
74
- name = k.decode("latin-1").title()
75
- headers[name] = v.decode("latin-1")
76
+ name, value = k.decode("latin-1").title(), v.decode("latin-1")
77
+ if name in headers: # repeated (HTTP/2 sends cookies as several headers)
78
+ value = headers[name] + ("; " if name == "Cookie" else ", ") + value
79
+ headers[name] = value
76
80
  path = scope.get("path", "/")
77
81
  if scope.get("query_string"):
78
82
  path += "?" + scope["query_string"].decode("latin-1")
@@ -81,7 +85,8 @@ def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_
81
85
  b'{"error": {"code": "http_413", "message": "request body too large"}}'
82
86
  else:
83
87
  status, hdrs, body = await asyncio.to_thread(
84
- site.respond, scope.get("method", "GET"), path, headers, b"".join(chunks))
88
+ site.respond, scope.get("method", "GET"), path, headers, b"".join(chunks),
89
+ (scope.get("client") or [None])[0])
85
90
  raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in hdrs]
86
91
  if hasattr(body, "aiter"): # Server-Sent Events, streamed RPC results
87
92
  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
 
@@ -262,13 +275,92 @@ def issue_session(data: dict, secret: str, max_age: int = 3600) -> str:
262
275
  return f"{payload_b64}.{_sign(payload_b64, secret)}"
263
276
 
264
277
 
265
- def verify_session(cookie: str, secret: str, max_age: int = 3600):
266
- """Verify a stateless session cookie; return payload dict or ``None``."""
278
+ class SessionVersions:
279
+ """Per-user session versions, for signing a user out everywhere (in memory: one process).
280
+
281
+ Sessions carry the version current when they were made; :func:`revoke_user`
282
+ bumps it, so every older session stops working.
283
+ """
284
+
285
+ def __init__(self):
286
+ self._v: dict = {}
287
+ self._lock = __import__("threading").Lock()
288
+
289
+ def get(self, user_id) -> int:
290
+ with self._lock:
291
+ return self._v.get(str(user_id), 0)
292
+
293
+ def bump(self, user_id) -> int:
294
+ with self._lock:
295
+ self._v[str(user_id)] = self._v.get(str(user_id), 0) + 1
296
+ return self._v[str(user_id)]
297
+
298
+
299
+ class RedisSessionVersions(SessionVersions):
300
+ """Session versions in Redis, shared by every server process."""
301
+
302
+ def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:sessver:"):
303
+ super().__init__()
304
+ if client is None:
305
+ import redis # noqa: PLC0415 - optional dependency
306
+ client = redis.Redis.from_url(url)
307
+ self._r, self._prefix = client, prefix
308
+
309
+ def get(self, user_id) -> int:
310
+ return int(self._r.get(self._prefix + str(user_id)) or 0)
311
+
312
+ def bump(self, user_id) -> int:
313
+ return int(self._r.incr(self._prefix + str(user_id)))
314
+
315
+
316
+ _versions: SessionVersions | None = None
317
+
318
+
319
+ def use_session_versions(store):
320
+ """Turn on revocation with ``store`` (a :class:`SessionVersions`); returns it."""
321
+ global _versions
322
+ _versions = store
323
+ return store
324
+
325
+
326
+ def session_versions():
327
+ return _versions
328
+
329
+
330
+ def revoke_user(user_id):
331
+ """Sign ``user_id`` out of every session (every device). Turns revocation on if needed."""
332
+ store = _versions if _versions is not None else use_session_versions(SessionVersions())
333
+ return store.bump(user_id)
334
+
335
+
336
+ #: Sessions end this long after sign-in, however active the user is.
337
+ ABSOLUTE_AGE = 30 * 24 * 3600
338
+
339
+
340
+ def session_valid(payload, *, absolute_age=ABSOLUTE_AGE, now=None) -> bool:
341
+ """Checks beyond the signature: the absolute lifetime and revocation."""
342
+ now = time.time() if now is None else now
343
+ started = payload.get("auth_time", payload.get("iat", 0))
344
+ if not isinstance(started, (int, float)) or now - started > absolute_age:
345
+ return False
346
+ if _versions is not None and "sub" in payload:
347
+ if int(payload.get("ver", 0) or 0) < _versions.get(payload["sub"]):
348
+ return False
349
+ return True
350
+
351
+
352
+ def verify_session(cookie: str, secret, max_age: int = 3600):
353
+ """Verify a stateless session cookie; return payload dict or ``None``.
354
+
355
+ ``secret`` may be a list of keys (key rotation): any of them may have signed it.
356
+ """
357
+ from .keys import any_valid
267
358
  try:
268
359
  payload_b64, sig = cookie.rsplit(".", 1)
269
- except ValueError:
360
+ except (ValueError, AttributeError):
270
361
  return None
271
- if not hmac.compare_digest(_sign(payload_b64, secret), sig):
362
+ candidates = secret if isinstance(secret, (list, tuple)) else [secret]
363
+ if not any_valid(candidates, lambda k: hmac.compare_digest(_sign(payload_b64, k), sig)):
272
364
  return None
273
365
  try:
274
366
  payload = json.loads(_b64d(payload_b64))
@@ -334,7 +426,8 @@ class OAuthClient:
334
426
  def issue_magic_token(secret: str, email: str, ttl: int = 900) -> str:
335
427
  payload = {"email": email, "exp": int(time.time()) + ttl}
336
428
  payload_b64 = _b64e(json.dumps(payload, separators=(",", ":")).encode())
337
- return f"{payload_b64}.{_sign(payload_b64, secret)}"
429
+ # Signed under its own prefix, so a session cookie can never pass as a login link (or back).
430
+ return f"{payload_b64}.{_sign('magic:' + payload_b64, secret)}"
338
431
 
339
432
 
340
433
  def verify_magic_token(secret: str, token: str, max_age: int = 900):
@@ -342,13 +435,13 @@ def verify_magic_token(secret: str, token: str, max_age: int = 900):
342
435
  payload_b64, sig = token.rsplit(".", 1)
343
436
  except ValueError:
344
437
  return None
345
- if not hmac.compare_digest(_sign(payload_b64, secret), sig):
438
+ if not hmac.compare_digest(_sign("magic:" + payload_b64, secret), sig):
346
439
  return None
347
440
  try:
348
441
  payload = json.loads(_b64d(payload_b64))
349
442
  except (ValueError, base64.binascii.Error, json.JSONDecodeError):
350
443
  return None
351
- if time.time() > payload.get("exp", 0):
444
+ if not isinstance(payload, dict) or time.time() > payload.get("exp", 0):
352
445
  return None
353
446
  return payload.get("email")
354
447
 
@@ -168,6 +168,13 @@ def build(compiled: dict, out: str, *, minifier=None, extract_css=True,
168
168
  os.makedirs(f"{out}/static", exist_ok=True)
169
169
  os.makedirs(f"{out}/server", exist_ok=True)
170
170
 
171
+ if app_dir and os.path.isfile(os.path.join(app_dir, "pyweb.lock")):
172
+ from pyweb import packages
173
+ problems = packages.verify(app_dir)
174
+ if problems:
175
+ listing = "\n".join(f" {path}: {why}" for path, why in problems[:20])
176
+ raise RuntimeError("npm package files don't match pyweb.lock (edited or tampered with?):\n"
177
+ f"{listing}\nRun `pyweb add` to reinstall them, then build again.")
171
178
  if app_dir and os.path.isdir(os.path.join(app_dir, "static")):
172
179
  shutil.copytree(os.path.join(app_dir, "static"), f"{out}/static", dirs_exist_ok=True)
173
180
 
@@ -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]:
@@ -87,23 +108,38 @@ class MemoryCache(Cache):
87
108
  self._tags.clear()
88
109
 
89
110
 
90
- def _dumps(value) -> bytes:
111
+ def _dumps(value, allow_pickle=False) -> bytes:
91
112
  try:
92
113
  return b"j:" + json.dumps(value).encode("utf-8")
93
114
  except (TypeError, ValueError):
115
+ if not allow_pickle:
116
+ raise TypeError(f"RedisCache stores JSON values; {type(value).__name__} isn't one "
117
+ "(convert it, or pass allow_pickle=True if only you can write to Redis)") from None
94
118
  return b"p:" + pickle.dumps(value)
95
119
 
96
120
 
97
- def _loads(raw: bytes):
121
+ _MISS = object()
122
+
123
+
124
+ def _loads(raw: bytes, allow_pickle=False):
98
125
  if raw.startswith(b"j:"):
99
126
  return json.loads(raw[2:].decode("utf-8"))
100
127
  if raw.startswith(b"p:"):
101
- return pickle.loads(raw[2:])
128
+ # Unpickling runs code chosen by whoever wrote the bytes: only with explicit trust.
129
+ return pickle.loads(raw[2:]) if allow_pickle else _MISS # noqa: S301
102
130
  return raw
103
131
 
104
132
 
105
133
  class RedisCache(Cache):
106
- def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:"):
134
+ """A cache in Redis, shared by every server process.
135
+
136
+ Values are stored as JSON. ``allow_pickle=True`` also stores other Python
137
+ objects with pickle; only use it when nobody else can write to your Redis,
138
+ because reading a pickle can run arbitrary code.
139
+ """
140
+
141
+ def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:", allow_pickle=False):
142
+ self.allow_pickle = allow_pickle
107
143
  if client is not None:
108
144
  self._r = client
109
145
  else:
@@ -124,7 +160,7 @@ class RedisCache(Cache):
124
160
  return f"{self._prefix}tag:{tag}"
125
161
 
126
162
  def set(self, key, value, ttl=None, tags=()):
127
- self._r.set(self._k(key), _dumps(value), ex=ttl)
163
+ self._r.set(self._k(key), _dumps(value, self.allow_pickle), ex=ttl)
128
164
  for t in tags:
129
165
  self._r.sadd(self._t(t), self._k(key))
130
166
 
@@ -132,7 +168,8 @@ class RedisCache(Cache):
132
168
  raw = self._r.get(self._k(key))
133
169
  if raw is None:
134
170
  return default
135
- return _loads(bytes(raw))
171
+ value = _loads(bytes(raw), self.allow_pickle)
172
+ return default if value is _MISS else value
136
173
 
137
174
  def delete(self, key):
138
175
  return bool(self._r.delete(self._k(key)))
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
- import http.server
7
6
  import os
8
7
  import sys
9
8
 
@@ -53,18 +52,66 @@ def cmd_check(args):
53
52
  findings.append({"kind": "compile-error", "line": 0, "message": str(exc)})
54
53
  for f in findings:
55
54
  print(f"{f['kind']} {args.file}:{f['line']}: {f['message']}")
56
- if any(f["kind"] in ("secret-leak", "compile-error") for f in findings):
55
+ problems = production_problems(src, os.path.dirname(os.path.abspath(args.file))) \
56
+ if getattr(args, "production", False) else []
57
+ for p in problems:
58
+ print(f"production: {p}")
59
+ if getattr(args, "production", False) and not problems:
60
+ print("production: ready")
61
+ if problems or any(f["kind"] in ("secret-leak", "compile-error") for f in findings):
57
62
  raise SystemExit(1)
58
63
 
59
64
 
65
+ def production_problems(source="", app_dir="."):
66
+ """What would make this app unsafe or unreliable in production, from the environment and the app."""
67
+ from pyweb import config
68
+ out = []
69
+ try:
70
+ s = config.settings()
71
+ except ValueError as exc:
72
+ return [str(exc)]
73
+ if not s.auth_secret:
74
+ out.append("PYWEB_AUTH_SECRET isn't set: sessions would use a development key "
75
+ "(generate one: python -c \"import secrets; print(secrets.token_hex(32))\")")
76
+ elif len(s.auth_secret) < 32:
77
+ out.append("PYWEB_AUTH_SECRET is shorter than 32 characters; use a long random value")
78
+ if not s.production:
79
+ out.append("PYWEB_ENV isn't 'production' (it makes a missing secret an error instead of a fallback)")
80
+ if not s.cookie_secure:
81
+ out.append("PYWEB_COOKIE_SECURE isn't on: cookies would also travel over plain HTTP "
82
+ "(set it when you serve over HTTPS, which you should)")
83
+ if not s.trust_proxy:
84
+ out.append("PYWEB_TRUST_PROXY isn't set: behind nginx, Caddy or a load balancer every visitor "
85
+ "looks like the proxy to rate limits (set it to 1, or the number of proxies; "
86
+ "ignore this if clients connect directly)")
87
+ try:
88
+ workers = int(os.environ.get("WEB_CONCURRENCY", "1") or 1)
89
+ except ValueError:
90
+ workers = 1
91
+ if workers > 1 and not s.redis_url:
92
+ out.append(f"WEB_CONCURRENCY={workers} without PYWEB_REDIS_URL: each worker would keep its own rate "
93
+ "limits and live updates (connect Redis to share them)")
94
+ if "allow_pickle=True" in (source or ""):
95
+ out.append("RedisCache(allow_pickle=True): anyone who can write to Redis could run code; store JSON")
96
+ if os.path.isfile(os.path.join(app_dir, "pyweb.lock")):
97
+ from pyweb import packages
98
+ for path, why in packages.verify(app_dir)[:5]:
99
+ out.append(f"{path}: {why} (run pyweb add to reinstall)")
100
+ return out
101
+
102
+
60
103
  def cmd_build(args):
61
104
  from pyweb.compiler import compile_source
62
105
  from pyweb.build import build as _build
63
106
  src = _load(args.file)
64
107
  out = compile_source(src, filename=args.file)
65
108
  production = getattr(args, "production", False)
66
- manifest = _build(out, args.out, source=src, production=production,
67
- app_dir=os.path.dirname(os.path.abspath(args.file)))
109
+ try:
110
+ manifest = _build(out, args.out, source=src, production=production,
111
+ app_dir=os.path.dirname(os.path.abspath(args.file)))
112
+ except RuntimeError as exc:
113
+ print(f"build failed: {exc}", file=sys.stderr)
114
+ raise SystemExit(1) from None
68
115
  mode = " (production)" if production else ""
69
116
  print(f"built {len(manifest['pages'])} page(s) + {len(out['rpc'])} rpc(s) -> {args.out}/{mode}")
70
117
  from pathlib import Path as _Path
@@ -137,14 +184,14 @@ def _dev_load(state, Site):
137
184
  def cmd_dev(args):
138
185
  """Development server: compile on save, live reload, error overlay."""
139
186
  from pyweb.hosting import Site
140
- from pyweb.serve import ThreadedServer
187
+ from pyweb.serve import LimitedHandler, ThreadedServer
141
188
 
142
189
  state = _DevState(args.file)
143
190
  _dev_load(state, Site)
144
191
  if state.error is not None:
145
192
  print(f"error: {state.error}", file=sys.stderr)
146
193
 
147
- class H(http.server.BaseHTTPRequestHandler):
194
+ class H(LimitedHandler):
148
195
  def _send(self, status, headers, body):
149
196
  from pyweb.hosting import write_http
150
197
  write_http(self, status, headers, body, head=self.command == "HEAD")
@@ -156,7 +203,8 @@ def cmd_dev(args):
156
203
  if state.error is not None or state.site is None:
157
204
  html = _error_overlay(state.error, state.path).encode()
158
205
  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)
206
+ status, headers, raw = state.site.respond(method, self.path, dict(self.headers), body,
207
+ client=self.client_address[0])
160
208
  ctype = next((v for k, v in headers if k.lower() == "content-type"), "")
161
209
  if ctype.startswith("text/html") and isinstance(raw, bytes) and b"</body>" in raw:
162
210
  raw = raw.replace(b"</body>", DEV_RELOAD_JS.encode() + b"</body>", 1)
@@ -193,12 +241,17 @@ def _watch_and_reload(state, Site):
193
241
 
194
242
  app_dir = os.path.dirname(os.path.abspath(state.path))
195
243
 
244
+ skip = {"__pycache__", "node_modules", "dist", "build", "venv", "env", "site-packages", "vendor"}
245
+
196
246
  def snapshot():
197
247
  stamps = {}
198
248
  for root, dirs, files in os.walk(app_dir):
199
- dirs[:] = [d for d in dirs if not d.startswith((".", "__pycache__", "node_modules", "dist"))]
249
+ # Virtualenvs (any folder with a pyvenv.cfg) hold thousands of files that never matter here;
250
+ # npm packages are covered by pyweb.lock, which changes whenever they do.
251
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip
252
+ and not os.path.exists(os.path.join(root, d, "pyvenv.cfg"))]
200
253
  for fn in files:
201
- if fn.endswith((".pyweb", ".py", ".css", ".js")):
254
+ if fn.endswith((".pyweb", ".py", ".css", ".js")) or fn == "pyweb.lock":
202
255
  p = os.path.join(root, fn)
203
256
  try:
204
257
  stamps[p] = os.path.getmtime(p)
@@ -358,8 +411,11 @@ def cmd_deploy(args):
358
411
  files = {}
359
412
  if target in ("docker", "compose"):
360
413
  files["Dockerfile"] = D.dockerfile(port=args.port)
414
+ domain = getattr(args, "domain", "") or ""
361
415
  if target == "compose" or args.compose:
362
- files["compose.yaml"] = D.compose(port=args.port, db_url=args.db_url or "")
416
+ files["compose.yaml"] = D.compose(port=args.port, db_url=args.db_url or "", domain=domain)
417
+ if domain:
418
+ files["Caddyfile"] = D.caddyfile(domain, port=args.port)
363
419
  elif target == "k8s":
364
420
  files["k8s.yaml"] = D.k8s_manifest(app=args.app, image=args.image, port=args.port)
365
421
  else:
@@ -368,6 +424,10 @@ def cmd_deploy(args):
368
424
  with open(os.path.join(outdir, name), "w") as fh:
369
425
  fh.write(body)
370
426
  print(f"deploy {target} -> {outdir}/ ({', '.join(files)})")
427
+ if "Caddyfile" in files:
428
+ print(f"next: point {args.domain}'s DNS at this server, then run\n"
429
+ f" PYWEB_AUTH_SECRET=... docker compose up -d (in {outdir}/, with your app files)\n"
430
+ "Caddy gets the HTTPS certificate by itself; the app is only reachable through it.")
371
431
 
372
432
 
373
433
  def cmd_dts(args):
@@ -438,7 +498,9 @@ def main(argv=None):
438
498
  p = sub.add_parser("new"); p.add_argument("name"); p.add_argument("--template", default="counter", choices=["blank", "counter", "todo", "blog", "auth", "chat", "ai-chat"], help="starter app"); p.set_defaults(fn=cmd_new)
439
499
  p = sub.add_parser("mcp", help="run the MCP server (stdio) for AI assistants"); p.set_defaults(fn=cmd_mcp)
440
500
  p = sub.add_parser("lsp", help="run the language server (stdio) for editors"); p.set_defaults(fn=cmd_lsp)
441
- p = sub.add_parser("check"); p.add_argument("file"); p.set_defaults(fn=cmd_check)
501
+ p = sub.add_parser("check"); p.add_argument("file")
502
+ p.add_argument("--production", action="store_true", help="also check the environment is ready for production")
503
+ p.set_defaults(fn=cmd_check)
442
504
  p = sub.add_parser("dts", help="generate Python stubs from a TypeScript .d.ts file (experimental)")
443
505
  p.add_argument("dts"); p.add_argument("-o", "--out", default=None); p.set_defaults(fn=cmd_dts)
444
506
  p = sub.add_parser("add", help="add npm packages for browser code (no Node.js needed)")
@@ -455,6 +517,7 @@ def main(argv=None):
455
517
  p.add_argument("--port", type=int, default=8000)
456
518
  p.add_argument("--compose", action="store_true")
457
519
  p.add_argument("--db-url", default="")
520
+ p.add_argument("--domain", default="", help="compose: put Caddy in front with automatic HTTPS for this domain")
458
521
  p.add_argument("--app", default="pyweb")
459
522
  p.add_argument("--image", default="pyweb:latest")
460
523
  p.set_defaults(fn=cmd_deploy)
@@ -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: