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