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.
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/PKG-INFO +7 -3
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/README.md +5 -1
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyproject.toml +2 -2
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/ai/guide.md +7 -2
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/asgi.py +8 -3
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/auth.py +102 -9
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/build.py +7 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/cache.py +44 -7
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/cli/__init__.py +74 -11
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/lower.py +33 -2
- pyweb_stack-0.4.4/pyweb/config.py +122 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/context.py +38 -6
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/db/__init__.py +21 -2
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/deploy.py +45 -2
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/dts.py +32 -12
- pyweb_stack-0.4.4/pyweb/hosting.py +306 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/jobs.py +51 -13
- pyweb_stack-0.4.4/pyweb/keys.py +61 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/livedata.py +6 -5
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/lsp.py +1 -1
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/markdown.py +1 -1
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/models.py +0 -1
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/packages.py +37 -5
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/realtime.py +51 -10
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/rpc.py +73 -5
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/runtime/browser/runtime.js +2 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/runtime/server/__init__.py +215 -26
- pyweb_stack-0.4.4/pyweb/runtime/server/argcheck.py +194 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/serve.py +91 -38
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/uploads.py +5 -1
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/PKG-INFO +7 -3
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/SOURCES.txt +7 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth_security.py +20 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_cli_deploy.py +20 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_db_production.py +29 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_deploy_upload_render.py +2 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_forms_security_obs.py +13 -1
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_jobs_cache_realtime.py +44 -2
- pyweb_stack-0.4.4/tests/test_keys_sessions.py +158 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_packages.py +32 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_parser_v1.py +38 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_production_gaps.py +2 -2
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_realtime_transport.py +25 -1
- pyweb_stack-0.4.4/tests/test_request_hardening.py +197 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_rpc_production.py +45 -0
- pyweb_stack-0.4.4/tests/test_static_serving.py +120 -0
- pyweb_stack-0.4.4/tests/test_systems.py +158 -0
- pyweb_stack-0.4.2/pyweb/hosting.py +0 -177
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/LICENSE +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/__init__.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/app.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/app_loader.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/bench.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/browser.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/cli/__main__.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/__init__.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/ast.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/__init__.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/ir.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/errors.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/parser.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/pipeline.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/pyjs.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/compiler/rpc.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/css.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/db/migrate.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/decorators.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/forms.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/livetable.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/mcp.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/observability.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/platform.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/plugins.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/py.typed +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/runtime/browser/markdown.js +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/security.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/ssr.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/sync.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/ai-chat.pyweb +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/app.css +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/auth.pyweb +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/blog.pyweb +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/chat.pyweb +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/counter.pyweb +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/templates/todo.pyweb +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb/testing.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/dependency_links.txt +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/entry_points.txt +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/requires.txt +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/top_level.txt +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/setup.cfg +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth_contract.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_auth_v1.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_backplane.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_browser_build.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_budget.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_codegen.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_compiler.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_docs.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_dts.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_e2e.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_examples.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_livedata.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_lsp.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_markdown.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_mcp.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_models_db.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_multifile.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_multipage.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_observability.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_parser.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_plugins_platform_bench.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_pyjs_semantics.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_reactivity.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_reference_apps.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_rpc_placement.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_runtime_signals.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_server_db.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_static_hashing.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_streaming.py +0 -0
- {pyweb_stack-0.4.2 → pyweb_stack-0.4.4}/tests/test_sync_live.py +0 -0
- {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.
|
|
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/
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
"""
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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(
|
|
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
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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:
|