pyweb-stack 0.2.0__tar.gz → 0.3.0__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 (110) hide show
  1. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/PKG-INFO +23 -7
  2. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/README.md +22 -6
  3. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyproject.toml +1 -1
  4. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/__init__.py +4 -0
  5. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/ai/guide.md +19 -7
  6. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/app_loader.py +46 -15
  7. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/asgi.py +25 -0
  8. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/auth.py +25 -3
  9. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/bench.py +31 -7
  10. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/build.py +7 -0
  11. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/cli/__init__.py +10 -9
  12. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/lower.py +97 -22
  13. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/pipeline.py +96 -9
  14. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/pyjs.py +3 -1
  15. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/db/__init__.py +1 -1
  16. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/hosting.py +37 -1
  17. pyweb_stack-0.3.0/pyweb/lsp.py +398 -0
  18. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/mcp.py +177 -9
  19. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/models.py +2 -2
  20. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/realtime.py +192 -0
  21. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/runtime/browser/runtime.js +181 -36
  22. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/runtime/server/__init__.py +38 -31
  23. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/serve.py +5 -0
  24. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/ssr.py +24 -6
  25. pyweb_stack-0.3.0/pyweb/templates/app.css +32 -0
  26. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/templates/chat.pyweb +9 -11
  27. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/testing.py +4 -8
  28. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb_stack.egg-info/PKG-INFO +23 -7
  29. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb_stack.egg-info/SOURCES.txt +1 -1
  30. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_auth.py +11 -0
  31. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_codegen.py +16 -0
  32. pyweb_stack-0.3.0/tests/test_lsp.py +187 -0
  33. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_mcp.py +24 -2
  34. pyweb_stack-0.3.0/tests/test_multifile.py +182 -0
  35. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_plugins_platform_bench.py +8 -0
  36. pyweb_stack-0.3.0/tests/test_realtime_transport.py +181 -0
  37. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_website.py +21 -1
  38. pyweb_stack-0.2.0/pyweb/lsp.py +0 -214
  39. pyweb_stack-0.2.0/pyweb/templates/app.css +0 -28
  40. pyweb_stack-0.2.0/tests/test_lsp.py +0 -81
  41. pyweb_stack-0.2.0/tests/test_lsp_state.py +0 -76
  42. pyweb_stack-0.2.0/tests/test_realtime_transport.py +0 -77
  43. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/LICENSE +0 -0
  44. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/app.py +0 -0
  45. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/browser.py +0 -0
  46. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/cache.py +0 -0
  47. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/cli/__main__.py +0 -0
  48. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/__init__.py +0 -0
  49. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/ast.py +0 -0
  50. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/codegen/__init__.py +0 -0
  51. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/codegen/ir.py +0 -0
  52. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/errors.py +0 -0
  53. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/parser.py +0 -0
  54. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/compiler/rpc.py +0 -0
  55. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/context.py +0 -0
  56. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/css.py +0 -0
  57. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/db/migrate.py +0 -0
  58. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/decorators.py +0 -0
  59. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/deploy.py +0 -0
  60. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/forms.py +0 -0
  61. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/jobs.py +0 -0
  62. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/live.py +0 -0
  63. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/npm.py +0 -0
  64. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/observability.py +0 -0
  65. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/platform.py +0 -0
  66. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/plugins.py +0 -0
  67. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/py.typed +0 -0
  68. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/rpc.py +0 -0
  69. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/security.py +0 -0
  70. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/sync.py +0 -0
  71. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/templates/auth.pyweb +0 -0
  72. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/templates/blog.pyweb +0 -0
  73. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/templates/counter.pyweb +0 -0
  74. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/templates/todo.pyweb +0 -0
  75. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb/uploads.py +0 -0
  76. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb_stack.egg-info/dependency_links.txt +0 -0
  77. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb_stack.egg-info/entry_points.txt +0 -0
  78. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb_stack.egg-info/requires.txt +0 -0
  79. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/pyweb_stack.egg-info/top_level.txt +0 -0
  80. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/setup.cfg +0 -0
  81. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_auth_contract.py +0 -0
  82. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_auth_security.py +0 -0
  83. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_auth_v1.py +0 -0
  84. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_backplane.py +0 -0
  85. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_browser_build.py +0 -0
  86. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_budget.py +0 -0
  87. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_cli_deploy.py +0 -0
  88. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_compiler.py +0 -0
  89. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_db_production.py +0 -0
  90. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_deploy_upload_render.py +0 -0
  91. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_docs.py +0 -0
  92. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_dts.py +0 -0
  93. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_e2e.py +0 -0
  94. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_examples.py +0 -0
  95. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_forms_security_obs.py +0 -0
  96. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_jobs_cache_realtime.py +0 -0
  97. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_models_db.py +0 -0
  98. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_observability.py +0 -0
  99. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_parser.py +0 -0
  100. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_parser_v1.py +0 -0
  101. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_production_gaps.py +0 -0
  102. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_pyjs_semantics.py +0 -0
  103. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_reactivity.py +0 -0
  104. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_reference_apps.py +0 -0
  105. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_rpc_placement.py +0 -0
  106. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_rpc_production.py +0 -0
  107. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_runtime_signals.py +0 -0
  108. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_server_db.py +0 -0
  109. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_static_hashing.py +0 -0
  110. {pyweb_stack-0.2.0 → pyweb_stack-0.3.0}/tests/test_sync_live.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyweb-stack
3
- Version: 0.2.0
3
+ Version: 0.3.0
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>, Claude <noreply@anthropic.com>
6
6
  Maintainer-email: MaanavKrishna <67054795+MaanavKrishna@users.noreply.github.com>
@@ -64,6 +64,9 @@ functions, without a JavaScript toolchain.
64
64
  ![PyPI](https://img.shields.io/pypi/v/pyweb-stack)
65
65
  ![License](https://img.shields.io/badge/license-MIT-lightgrey)
66
66
 
67
+ **[Try it in your browser](https://maanavkrishna.github.io/PyWeb/playground.html)**: the playground runs
68
+ the real PyWeb, server functions included, on Python compiled to WebAssembly.
69
+
67
70
  ```pyweb
68
71
  from pyweb import App, server
69
72
 
@@ -143,8 +146,8 @@ and [current limitations](docs/16-limitations-roadmap.md).
143
146
  ## Build it with AI
144
147
 
145
148
  PyWeb ships an MCP server so AI assistants can scaffold, check, inspect,
146
- render and test your app, with errors that come back as line numbers and
147
- fix hints:
149
+ render, screenshot and test your app, with errors that come back as line
150
+ numbers and fix hints:
148
151
 
149
152
  ```bash
150
153
  claude mcp add pyweb -- pyweb mcp # Claude Code
@@ -185,7 +188,7 @@ a real browser by the test suite.
185
188
  | [`todo`](examples/todo/app.pyweb) | components, list mutation, filters, keyed lists |
186
189
  | [`blog`](examples/blog/app.pyweb) | SQL database, server functions, route params, 404s, validation errors |
187
190
  | [`auth`](examples/auth/app.pyweb) | registration, password hashing, sessions, protected pages |
188
- | [`chat`](examples/chat/app.pyweb) | route params, shared server state, polling with `on_mount` |
191
+ | [`chat`](examples/chat/app.pyweb) | route params, shared server state, live updates with `publish`/`subscribe` |
189
192
  | [`showcase`](examples/showcase/app.pyweb) | everything on one page, with a stylesheet |
190
193
 
191
194
  ## Command line
@@ -198,13 +201,26 @@ pyweb check app.pyweb # compile + security checks for
198
201
  pyweb build app.pyweb --out dist --production # self-contained, hashed, minified dist/
199
202
  pyweb serve dist # production server (/healthz, CSP, graceful shutdown)
200
203
  pyweb mcp # MCP server for AI assistants (stdio)
204
+ pyweb lsp # language server for editors (stdio)
201
205
  ```
202
206
 
207
+ Editors: the [VS Code extension](editors/vscode) adds highlighting, errors as you
208
+ type, hover that shows where code runs, completion and go to definition. Any
209
+ other LSP editor can run `pyweb lsp` ([setup](docs/14-cli.md#editor-support)).
210
+
203
211
  ## Status
204
212
 
205
- PyWeb 0.1 is the first public release (beta). The language, server API,
206
- RPC protocol and CLI are documented and tested, and changes to them are
207
- announced in the changelog (see [stability](docs/15-toolkit.md#stability)). The test suite covers the
213
+ PyWeb is in beta (0.x; see the PyPI badge above for the latest version).
214
+ The language, server API, RPC protocol and CLI are documented and tested,
215
+ and changes to them are announced in the [changelog](CHANGELOG.md) (see
216
+ [stability](docs/15-toolkit.md#stability)). Upgrade with
217
+ `pip install -U pyweb-stack`.
218
+
219
+ | Version | Highlights |
220
+ |---|---|
221
+ | 0.3 | Hydration, live updates (SSE), multi-file apps, language server + VS Code extension, browser playground, faster rendering, screenshot/test MCP tools |
222
+ | 0.2 | MCP server for AI assistants, AI guide, project templates, `AGENTS.md`/`CLAUDE.md`, `llms.txt` |
223
+ | 0.1 | First public release: compiler, reactive runtime, server rendering, typed RPC, sessions, databases, CLI | The test suite covers the
208
224
  parser, the Python→JavaScript translation (differentially, against
209
225
  CPython), the reactive runtime, server rendering, RPC, sessions, every
210
226
  example app in Chromium, and the database/Redis layers against real
@@ -10,6 +10,9 @@ functions, without a JavaScript toolchain.
10
10
  ![PyPI](https://img.shields.io/pypi/v/pyweb-stack)
11
11
  ![License](https://img.shields.io/badge/license-MIT-lightgrey)
12
12
 
13
+ **[Try it in your browser](https://maanavkrishna.github.io/PyWeb/playground.html)**: the playground runs
14
+ the real PyWeb, server functions included, on Python compiled to WebAssembly.
15
+
13
16
  ```pyweb
14
17
  from pyweb import App, server
15
18
 
@@ -89,8 +92,8 @@ and [current limitations](docs/16-limitations-roadmap.md).
89
92
  ## Build it with AI
90
93
 
91
94
  PyWeb ships an MCP server so AI assistants can scaffold, check, inspect,
92
- render and test your app, with errors that come back as line numbers and
93
- fix hints:
95
+ render, screenshot and test your app, with errors that come back as line
96
+ numbers and fix hints:
94
97
 
95
98
  ```bash
96
99
  claude mcp add pyweb -- pyweb mcp # Claude Code
@@ -131,7 +134,7 @@ a real browser by the test suite.
131
134
  | [`todo`](examples/todo/app.pyweb) | components, list mutation, filters, keyed lists |
132
135
  | [`blog`](examples/blog/app.pyweb) | SQL database, server functions, route params, 404s, validation errors |
133
136
  | [`auth`](examples/auth/app.pyweb) | registration, password hashing, sessions, protected pages |
134
- | [`chat`](examples/chat/app.pyweb) | route params, shared server state, polling with `on_mount` |
137
+ | [`chat`](examples/chat/app.pyweb) | route params, shared server state, live updates with `publish`/`subscribe` |
135
138
  | [`showcase`](examples/showcase/app.pyweb) | everything on one page, with a stylesheet |
136
139
 
137
140
  ## Command line
@@ -144,13 +147,26 @@ pyweb check app.pyweb # compile + security checks for
144
147
  pyweb build app.pyweb --out dist --production # self-contained, hashed, minified dist/
145
148
  pyweb serve dist # production server (/healthz, CSP, graceful shutdown)
146
149
  pyweb mcp # MCP server for AI assistants (stdio)
150
+ pyweb lsp # language server for editors (stdio)
147
151
  ```
148
152
 
153
+ Editors: the [VS Code extension](editors/vscode) adds highlighting, errors as you
154
+ type, hover that shows where code runs, completion and go to definition. Any
155
+ other LSP editor can run `pyweb lsp` ([setup](docs/14-cli.md#editor-support)).
156
+
149
157
  ## Status
150
158
 
151
- PyWeb 0.1 is the first public release (beta). The language, server API,
152
- RPC protocol and CLI are documented and tested, and changes to them are
153
- announced in the changelog (see [stability](docs/15-toolkit.md#stability)). The test suite covers the
159
+ PyWeb is in beta (0.x; see the PyPI badge above for the latest version).
160
+ The language, server API, RPC protocol and CLI are documented and tested,
161
+ and changes to them are announced in the [changelog](CHANGELOG.md) (see
162
+ [stability](docs/15-toolkit.md#stability)). Upgrade with
163
+ `pip install -U pyweb-stack`.
164
+
165
+ | Version | Highlights |
166
+ |---|---|
167
+ | 0.3 | Hydration, live updates (SSE), multi-file apps, language server + VS Code extension, browser playground, faster rendering, screenshot/test MCP tools |
168
+ | 0.2 | MCP server for AI assistants, AI guide, project templates, `AGENTS.md`/`CLAUDE.md`, `llms.txt` |
169
+ | 0.1 | First public release: compiler, reactive runtime, server rendering, typed RPC, sessions, databases, CLI | The test suite covers the
154
170
  parser, the Python→JavaScript translation (differentially, against
155
171
  CPython), the reactive runtime, server rendering, RPC, sessions, every
156
172
  example app in Chromium, and the database/Redis layers against real
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyweb-stack"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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" }
@@ -10,6 +10,7 @@ from .jobs import task
10
10
  from .context import NotFound, redirect, request, session
11
11
  from .rpc import RPCError
12
12
  from .decorators import component, edge, server, worker
13
+ from .realtime import channel, publish, subscribe
13
14
 
14
15
  __all__ = [
15
16
  "App",
@@ -22,6 +23,9 @@ __all__ = [
22
23
  "edge",
23
24
  "server",
24
25
  "worker",
26
+ "channel",
27
+ "publish",
28
+ "subscribe",
25
29
  "Email",
26
30
  "Model",
27
31
  "task",
@@ -135,8 +135,13 @@ def Home():
135
135
 
136
136
  Props are parameters (defaults = optional). Pass callbacks as props
137
137
  (`on_delete={lambda: delete(i)}`) and use them as handlers inside
138
- (`onclick={on_delete}`). Components must be in the same file and their
139
- initial state must be computable in the browser (pass server data as props).
138
+ (`onclick={on_delete}`). A component's initial state must be computable in
139
+ the browser (pass server data as props).
140
+
141
+ Bigger apps can split into files: put components, `@server` functions and
142
+ constants in e.g. `widgets.pyweb` (or `ui/cards.pyweb`) next to `app.pyweb`
143
+ and `from widgets import Card, save`. Pages stay in `app.pyweb`; import
144
+ server functions without `as`; each file keeps its own constants.
140
145
 
141
146
  ## 6. Server functions, sessions, routing
142
147
 
@@ -167,8 +172,12 @@ def Item(item_id: int):
167
172
  `db.execute("select ... where id = ?", (x,)).dicts()`; always use `?`
168
173
  parameters; `with db.transaction(): ...`.
169
174
  - In handlers, navigate with `window.location.href = "/path"`.
170
- - Run code after load with a handler named `on_mount` (e.g.
171
- `setInterval(refresh, 2000)` for polling).
175
+ - Run code after load with a handler named `on_mount`.
176
+ - Live updates: in a server function `publish("room:1", data)`; in the
177
+ page `feed = channel("room:1")` (runs on the server); in `on_mount`
178
+ `subscribe(feed, handler)`, where `handler(message)` assigns page
179
+ variables. Import all three from `pyweb`. Never poll with
180
+ `setInterval` when `publish` fits.
172
181
 
173
182
  ## 7. Python that compiles to the browser
174
183
 
@@ -195,7 +204,7 @@ JS functions, server-only names. Move such code into `@server` functions.
195
204
  | `cannot assign to ... derived/read-only` | Assign to a variable the handler owns (make it state), not a computed/constant. |
196
205
  | `server secret ... would be sent to the browser` | Keep the value inside `@server` functions; don't read it in markup/handlers. |
197
206
  | `bind={x} must name a local variable` | Declare `x = ""` (or a number/bool) in the page before the markup. |
198
- | `unknown component <X>` | Define `def X(...)` with markup in the same file (capitalized). |
207
+ | `unknown component <X>` | Define `def X(...)` with markup (capitalized) in this file, or import it: `from widgets import X`. |
199
208
  | `mismatched </tag>` / `is never closed` | Close every tag; void tags (`input`, `img`, `br`) need no close (`<input ... />`). |
200
209
  | `... is not supported in browser code` | Rewrite with supported constructs or move it to `@server`. |
201
210
 
@@ -208,8 +217,11 @@ JS functions, server-only names. Move such code into `@server` functions.
208
217
  3. Use `pyweb inspect` (MCP: `pyweb_inspect`) to confirm what runs in the
209
218
  browser vs server and what is sent to the browser.
210
219
  4. Verify behaviour: render pages (`pyweb_render`) and call server
211
- functions (`pyweb_call`), or write tests with `pyweb.testing.TestClient`.
212
- 5. Ship: `pyweb build app.pyweb --out dist --production` then
220
+ functions (`pyweb_call`). See the page and try interactions in a real
221
+ browser with `pyweb_screenshot` (steps: click, fill, press, ...).
222
+ 5. Test: new apps include `test_app.py` (`pyweb.testing.TestClient`); add
223
+ tests for what you change and run `pytest` (MCP: `pyweb_test`).
224
+ 6. Ship: `pyweb build app.pyweb --out dist --production` then
213
225
  `pyweb serve dist` (set `PYWEB_AUTH_SECRET` in production).
214
226
 
215
227
  Full docs: https://maanavkrishna.github.io/PyWeb/
@@ -18,7 +18,7 @@ from .compiler import compile_source
18
18
  from .compiler import parser as P
19
19
  from .compiler.lower import is_ui_stmt
20
20
  from .context import NotFound, Redirect
21
- from .ssr import Renderer, page_html
21
+ from .ssr import NS_KEY, Renderer, page_html
22
22
 
23
23
  STATE_PREFIX = "__pyweb_state_"
24
24
 
@@ -53,12 +53,30 @@ class LoadedApp:
53
53
  self.extra_stylesheets = list(stylesheets or [])
54
54
  self.compiled = compile_source(source, filename=self.filename)
55
55
  self.ctx = self.compiled["context"]
56
- self.module = self._exec()
56
+ # Other .pyweb files the app imports run as real modules, registered
57
+ # under their import name so `from widgets import Card` works.
58
+ self.libraries = self.compiled["libraries"]
59
+ self.lib_modules = {}
60
+ for lib in self.libraries:
61
+ with open(lib.path, encoding="utf-8") as fh:
62
+ lib_source = fh.read()
63
+ infos = list(lib.components.values())
64
+ self.lib_modules[lib.path] = self._exec(lib_source, lib.path, infos, lib.name)
65
+ self.files = [os.path.abspath(path)] if path else []
66
+ self.files += [lib.path for lib in self.libraries]
67
+ infos = [p["info"] for p in self.compiled["pages"].values()]
68
+ infos += list(self.compiled["components"].values())
69
+ self.module = self._exec(source, self.filename, infos)
57
70
  self.rpc = {}
58
71
  for spec in self.compiled["rpc"]:
59
72
  fn = self.module.__dict__.get(spec["name"])
60
73
  if callable(fn):
61
74
  self.rpc[spec["name"]] = fn
75
+ for lib in self.libraries:
76
+ for spec in lib.rpc:
77
+ fn = self.lib_modules[lib.path].__dict__.get(spec["name"])
78
+ if callable(fn):
79
+ self.rpc[spec["name"]] = fn
62
80
  app_obj = next((v for v in self.module.__dict__.values()
63
81
  if type(v).__name__ == "App" and type(v).__module__.startswith("pyweb")), None)
64
82
  self.app = app_obj
@@ -67,29 +85,42 @@ class LoadedApp:
67
85
  self.lang = getattr(app_obj, "lang", "en") or "en"
68
86
 
69
87
  # ----------------------------------------------------------- module
70
- def _exec(self):
71
- py_source, _ui = P.split_sources(self.source)
72
- tree = ast.parse(py_source, filename=self.filename)
73
- infos = [p["info"] for p in self.compiled["pages"].values()]
74
- infos += list(self.compiled["components"].values())
88
+ def _exec(self, source, filename, infos, module_name=None):
89
+ py_source, _ui = P.split_sources(source)
90
+ tree = ast.parse(py_source, filename=filename)
75
91
  for info in infos:
76
92
  tree.body.append(_state_function(info.node))
77
- stem = os.path.splitext(os.path.basename(self.filename))[0] or "app"
78
- mod = types.ModuleType(f"pyweb_app_{stem}")
79
- mod.__file__ = os.path.abspath(self.filename) if self.path else self.filename
93
+ stem = os.path.splitext(os.path.basename(filename))[0] or "app"
94
+ mod = types.ModuleType(module_name or f"pyweb_app_{stem}")
95
+ mod.__file__ = os.path.abspath(filename) if self.path else filename
80
96
  mod.__dict__["__pyweb_ui__"] = lambda *_a: None
81
97
  app_dir = os.path.dirname(os.path.abspath(self.path)) if self.path else None
82
98
  if app_dir and app_dir not in sys.path:
83
99
  sys.path.insert(0, app_dir)
84
100
  sys.modules[mod.__name__] = mod
85
- exec(compile(tree, self.filename, "exec"), mod.__dict__) # noqa: S102 - the app itself
101
+ exec(compile(tree, filename, "exec"), mod.__dict__) # noqa: S102 - the app itself
86
102
  return mod
87
103
 
88
104
  # -------------------------------------------------------- rendering
89
- def _component_state(self, name, props):
90
- info = self.compiled["components"][name]
91
- fn = self.module.__dict__[STATE_PREFIX + name]
92
- env = fn(**{k: v for k, v in props.items() if k in info.params})
105
+ def _component_state(self, name, props, ns=None):
106
+ """Run component ``name`` as used by the file ``ns`` (None: the app)."""
107
+ ctx = ns or self.ctx
108
+ source = ctx.imported_components.get(name)
109
+ if source is not None:
110
+ lib, name = source
111
+ ctx, module = lib.ctx, self.lib_modules[lib.path]
112
+ else:
113
+ module = self.module if ctx is self.ctx else next(
114
+ self.lib_modules[lib.path] for lib in self.libraries if lib.ctx is ctx)
115
+ info = ctx.components[name]
116
+ fn = module.__dict__[STATE_PREFIX + name]
117
+ local = fn(**{k: v for k, v in props.items() if k in info.params})
118
+ if module is self.module:
119
+ return info.ui, local
120
+ # Markup in another file sees that file's globals.
121
+ env = {k: v for k, v in module.__dict__.items() if not k.startswith("__")}
122
+ env.update(local)
123
+ env[NS_KEY] = ctx
93
124
  return info.ui, env
94
125
 
95
126
  def renderer(self):
@@ -20,6 +20,27 @@ import asyncio
20
20
  from .hosting import Site
21
21
 
22
22
 
23
+ async def _stream(stream, receive, send):
24
+ """Send an event stream until it ends or the client disconnects."""
25
+ async def watch():
26
+ while (await receive())["type"] != "http.disconnect":
27
+ pass
28
+ stream.close()
29
+
30
+ watcher = asyncio.ensure_future(watch())
31
+ try:
32
+ async for chunk in stream.aiter():
33
+ if watcher.done():
34
+ break
35
+ await send({"type": "http.response.body", "body": chunk, "more_body": True})
36
+ await send({"type": "http.response.body", "body": b""})
37
+ except OSError:
38
+ pass
39
+ finally:
40
+ stream.close()
41
+ watcher.cancel()
42
+
43
+
23
44
  def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_kwargs):
24
45
  """Return an ASGI 3 application serving ``target`` (a `.pyweb` file or dist dir)."""
25
46
  site = Site(target, debug=debug, max_body=max_body, **server_kwargs)
@@ -62,6 +83,10 @@ def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_
62
83
  status, hdrs, body = await asyncio.to_thread(
63
84
  site.respond, scope.get("method", "GET"), path, headers, b"".join(chunks))
64
85
  raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in hdrs]
86
+ if hasattr(body, "aiter"): # Server-Sent Events
87
+ await send({"type": "http.response.start", "status": status, "headers": raw_headers})
88
+ await _stream(body, receive, send)
89
+ return
65
90
  if not any(k == b"content-length" for k, _ in raw_headers):
66
91
  raw_headers.append((b"content-length", str(len(body)).encode()))
67
92
  await send({"type": "http.response.start", "status": status, "headers": raw_headers})
@@ -37,14 +37,36 @@ def _b64d(data: str) -> bytes:
37
37
  return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
38
38
 
39
39
 
40
+ def _pbkdf2_sha256(password: bytes, salt: bytes, iterations: int) -> bytes:
41
+ """PBKDF2-HMAC-SHA256 (32-byte key). Uses hashlib's C version when it
42
+ exists; Pythons built without OpenSSL (e.g. Pyodide) get this pure
43
+ Python one, which gives the same result."""
44
+ fast = getattr(hashlib, "pbkdf2_hmac", None)
45
+ if fast is not None:
46
+ return fast("sha256", password, salt, iterations)
47
+ mac = hmac.new(password, digestmod=hashlib.sha256)
48
+
49
+ def prf(data):
50
+ m = mac.copy()
51
+ m.update(data)
52
+ return m.digest()
53
+
54
+ u = prf(salt + b"\x00\x00\x00\x01")
55
+ acc = int.from_bytes(u, "big")
56
+ for _ in range(iterations - 1):
57
+ u = prf(u)
58
+ acc ^= int.from_bytes(u, "big")
59
+ return acc.to_bytes(32, "big")
60
+
61
+
40
62
  def hash_password(password, *, salt=None, rounds=None, iterations=_ITERATIONS):
41
63
  if salt is None:
42
64
  salt_bytes = secrets.token_bytes(16)
43
- dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_bytes, iterations)
65
+ dk = _pbkdf2_sha256(password.encode(), salt_bytes, iterations)
44
66
  return f"{_HASH_ALGO}${iterations}${_b64e(salt_bytes)}${_b64e(dk)}"
45
67
  salt_bytes = salt if isinstance(salt, bytes) else bytes.fromhex(salt)
46
68
  n = rounds if rounds is not None else iterations
47
- dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_bytes, n)
69
+ dk = _pbkdf2_sha256(password.encode(), salt_bytes, n)
48
70
  return f"pbkdf2${n}${salt_bytes.hex()}${dk.hex()}"
49
71
 
50
72
 
@@ -63,7 +85,7 @@ def verify_password(password, stored):
63
85
  expected = bytes.fromhex(dk_part)
64
86
  except (ValueError, base64.binascii.Error):
65
87
  return False
66
- candidate = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iters_i)
88
+ candidate = _pbkdf2_sha256(password.encode(), salt, iters_i)
67
89
  if algo == _HASH_ALGO:
68
90
  return hmac.compare_digest(candidate, expected)
69
91
  return hmac.compare_digest(candidate.hex(), expected.hex() if isinstance(expected, bytes) else dk_part)
@@ -87,15 +87,39 @@ def run(apps=None) -> dict:
87
87
  return {name: bench_app(name, src) for name, src in apps.items()}
88
88
 
89
89
 
90
- def main():
90
+ def over_budget(results, *, max_ssr_ms=None, max_compile_ms=None) -> list:
91
+ """Lines describing every app that exceeds a budget."""
92
+ out = []
93
+ for r in results.values():
94
+ if max_ssr_ms is not None and r["ssr_ms"] > max_ssr_ms:
95
+ out.append(f"{r['app']}: server render {r['ssr_ms']} ms > {max_ssr_ms} ms")
96
+ if max_compile_ms is not None and r["compile_ms"] > max_compile_ms:
97
+ out.append(f"{r['app']}: compile {r['compile_ms']} ms > {max_compile_ms} ms")
98
+ return out
99
+
100
+
101
+ def main(argv=None):
102
+ import argparse
103
+ import sys
104
+ ap = argparse.ArgumentParser(prog="python -m pyweb.bench", description="Measure compile time, "
105
+ "shipped JavaScript and server render time.")
106
+ ap.add_argument("--json", action="store_true", help="print only JSON")
107
+ ap.add_argument("--max-ssr-ms", type=float, help="fail if any server render is slower")
108
+ ap.add_argument("--max-compile-ms", type=float, help="fail if any compile is slower")
109
+ args = ap.parse_args(argv)
91
110
  results = run()
92
111
  print(json.dumps(results, indent=2))
93
- rt = runtime_sizes()
94
- print(f"\nshared runtime: {rt['bytes']} B minified, {rt['gzip_bytes']} B gzip "
95
- "(downloaded once, cached across pages)")
96
- for r in results.values():
97
- print(f"{r['app']:>8}: page JS {r['page_js_gzip']} B gzip, server render {r['ssr_ms']} ms")
112
+ if not args.json:
113
+ rt = runtime_sizes()
114
+ print(f"\nshared runtime: {rt['bytes']} B minified, {rt['gzip_bytes']} B gzip "
115
+ "(downloaded once, cached across pages)")
116
+ for r in results.values():
117
+ print(f"{r['app']:>8}: page JS {r['page_js_gzip']} B gzip, server render {r['ssr_ms']} ms")
118
+ failed = over_budget(results, max_ssr_ms=args.max_ssr_ms, max_compile_ms=args.max_compile_ms)
119
+ for line in failed:
120
+ print("over budget:", line, file=sys.stderr)
121
+ return 1 if failed else 0
98
122
 
99
123
 
100
124
  if __name__ == "__main__":
101
- main()
125
+ raise SystemExit(main())
@@ -235,6 +235,13 @@ def build(compiled: dict, out: str, *, minifier=None, extract_css=True,
235
235
  if source is not None:
236
236
  with open(f"{out}/app.pyweb", "w", encoding="utf-8") as fh:
237
237
  fh.write(source)
238
+ # Other .pyweb files the app imports, at the same relative paths.
239
+ for lib in compiled.get("libraries", []):
240
+ rel = os.path.relpath(lib.path, app_dir) if app_dir else os.path.basename(lib.path)
241
+ if rel.startswith(".."):
242
+ rel = os.path.basename(lib.path)
243
+ os.makedirs(os.path.dirname(os.path.join(out, rel)) or out, exist_ok=True)
244
+ shutil.copyfile(lib.path, os.path.join(out, rel))
238
245
  try:
239
246
  from pyweb import __version__ as pyweb_version
240
247
  except ImportError: # pragma: no cover
@@ -139,14 +139,8 @@ def cmd_dev(args):
139
139
 
140
140
  class H(http.server.BaseHTTPRequestHandler):
141
141
  def _send(self, status, headers, body):
142
- self.send_response(status)
143
- for k, v in headers:
144
- if k.lower() != "content-length":
145
- self.send_header(k, v)
146
- self.send_header("Content-Length", str(len(body)))
147
- self.end_headers()
148
- if self.command != "HEAD":
149
- self.wfile.write(body)
142
+ from pyweb.hosting import write_http
143
+ write_http(self, status, headers, body, head=self.command == "HEAD")
150
144
 
151
145
  def _handle(self, method, body=b""):
152
146
  if self.path == "/__pyweb/dev/version":
@@ -157,7 +151,7 @@ def cmd_dev(args):
157
151
  return self._send(500, [("Content-Type", "text/html; charset=utf-8")], html)
158
152
  status, headers, raw = state.site.respond(method, self.path, dict(self.headers), body)
159
153
  ctype = next((v for k, v in headers if k.lower() == "content-type"), "")
160
- if ctype.startswith("text/html") and b"</body>" in raw:
154
+ if ctype.startswith("text/html") and isinstance(raw, bytes) and b"</body>" in raw:
161
155
  raw = raw.replace(b"</body>", DEV_RELOAD_JS.encode() + b"</body>", 1)
162
156
  return self._send(status, headers, raw)
163
157
 
@@ -393,6 +387,12 @@ def cmd_new(args):
393
387
  print(f"next: cd {args.name} && pyweb dev app.pyweb")
394
388
 
395
389
 
390
+ def cmd_lsp(args):
391
+ """Language server for editors (stdio)."""
392
+ from pyweb.lsp import serve_stdio
393
+ serve_stdio()
394
+
395
+
396
396
  def cmd_mcp(args):
397
397
  from pyweb.mcp import serve_stdio
398
398
  serve_stdio()
@@ -410,6 +410,7 @@ def main(argv=None):
410
410
  p = sub.add_parser("db"); p.add_argument("db_action", choices=["migrate", "new", "status", "rollback"]); p.add_argument("--database", default=None); p.add_argument("--migrations", default="migrations"); p.add_argument("--name", default="migration"); p.add_argument("--steps", type=int, default=1, help="rollback: how many applied migrations to revert"); p.add_argument("--to", default=None, help="rollback: revert everything applied after this label"); p.set_defaults(fn=cmd_db)
411
411
  p = sub.add_parser("new"); p.add_argument("name"); p.add_argument("--template", default="counter", choices=["blank", "counter", "todo", "blog", "auth", "chat"], help="starter app"); p.set_defaults(fn=cmd_new)
412
412
  p = sub.add_parser("mcp", help="run the MCP server (stdio) for AI assistants"); p.set_defaults(fn=cmd_mcp)
413
+ p = sub.add_parser("lsp", help="run the language server (stdio) for editors"); p.set_defaults(fn=cmd_lsp)
413
414
  p = sub.add_parser("check"); p.add_argument("file"); p.set_defaults(fn=cmd_check)
414
415
  p = sub.add_parser("npm"); p.add_argument("dts"); p.add_argument("-o", "--out", default=None); p.set_defaults(fn=cmd_npm)
415
416
  p = sub.add_parser("deploy")