pyweb-stack 0.4.3__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.3 → pyweb_stack-0.4.4}/PKG-INFO +2 -2
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/README.md +1 -1
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyproject.toml +1 -1
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/ai/guide.md +6 -1
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/asgi.py +2 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/auth.py +83 -4
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/build.py +7 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/cache.py +22 -6
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/cli/__init__.py +65 -8
- pyweb_stack-0.4.4/pyweb/config.py +122 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/context.py +38 -6
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/db/__init__.py +10 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/deploy.py +45 -2
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/hosting.py +68 -11
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/jobs.py +16 -0
- pyweb_stack-0.4.4/pyweb/keys.py +61 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/livedata.py +6 -5
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/packages.py +35 -1
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/realtime.py +19 -3
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/rpc.py +53 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/runtime/server/__init__.py +173 -27
- pyweb_stack-0.4.4/pyweb/runtime/server/argcheck.py +194 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/serve.py +76 -15
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/PKG-INFO +2 -2
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/SOURCES.txt +6 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_cli_deploy.py +20 -0
- pyweb_stack-0.4.4/tests/test_keys_sessions.py +158 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_packages.py +32 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_production_gaps.py +2 -2
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_realtime_transport.py +3 -1
- pyweb_stack-0.4.4/tests/test_request_hardening.py +197 -0
- pyweb_stack-0.4.4/tests/test_systems.py +158 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/LICENSE +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/__init__.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/app.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/app_loader.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/bench.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/browser.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/cli/__main__.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/__init__.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/ast.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/__init__.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/ir.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/errors.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/lower.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/parser.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/pipeline.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/pyjs.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/rpc.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/css.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/db/migrate.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/decorators.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/dts.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/forms.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/livetable.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/lsp.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/markdown.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/mcp.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/models.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/observability.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/platform.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/plugins.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/py.typed +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/runtime/browser/markdown.js +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/runtime/browser/runtime.js +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/security.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/ssr.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/sync.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/ai-chat.pyweb +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/app.css +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/auth.pyweb +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/blog.pyweb +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/chat.pyweb +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/counter.pyweb +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/todo.pyweb +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/testing.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/uploads.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/dependency_links.txt +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/entry_points.txt +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/requires.txt +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/top_level.txt +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/setup.cfg +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth_contract.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth_security.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth_v1.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_backplane.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_browser_build.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_budget.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_codegen.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_compiler.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_db_production.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_deploy_upload_render.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_docs.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_dts.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_e2e.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_examples.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_forms_security_obs.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_jobs_cache_realtime.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_livedata.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_lsp.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_markdown.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_mcp.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_models_db.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_multifile.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_multipage.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_observability.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_parser.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_parser_v1.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_plugins_platform_bench.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_pyjs_semantics.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_reactivity.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_reference_apps.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_rpc_placement.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_rpc_production.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_runtime_signals.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_server_db.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_static_hashing.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_static_serving.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_streaming.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_sync_live.py +0 -0
- {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_website.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
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>
|
|
@@ -384,7 +384,7 @@ Upgrade with `pip install -U pyweb-stack`.
|
|
|
384
384
|
|
|
385
385
|
| Version | Highlights |
|
|
386
386
|
|---|---|
|
|
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 |
|
|
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` |
|
|
388
388
|
| 0.3 | Hydration, live updates (SSE), multi-file apps, language server + VS Code extension, browser playground, faster rendering, screenshot/test MCP tools |
|
|
389
389
|
| 0.2 | MCP server for AI assistants, AI guide, project templates, `AGENTS.md`/`CLAUDE.md`, `llms.txt` |
|
|
390
390
|
| 0.1 | First public release: compiler, reactive runtime, server rendering, typed RPC, sessions, databases, CLI |
|
|
@@ -330,7 +330,7 @@ Upgrade with `pip install -U pyweb-stack`.
|
|
|
330
330
|
|
|
331
331
|
| Version | Highlights |
|
|
332
332
|
|---|---|
|
|
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 |
|
|
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` |
|
|
334
334
|
| 0.3 | Hydration, live updates (SSE), multi-file apps, language server + VS Code extension, browser playground, faster rendering, screenshot/test MCP tools |
|
|
335
335
|
| 0.2 | MCP server for AI assistants, AI guide, project templates, `AGENTS.md`/`CLAUDE.md`, `llms.txt` |
|
|
336
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" }
|
|
@@ -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":
|
|
@@ -275,13 +275,92 @@ def issue_session(data: dict, secret: str, max_age: int = 3600) -> str:
|
|
|
275
275
|
return f"{payload_b64}.{_sign(payload_b64, secret)}"
|
|
276
276
|
|
|
277
277
|
|
|
278
|
-
|
|
279
|
-
"""
|
|
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
|
|
280
358
|
try:
|
|
281
359
|
payload_b64, sig = cookie.rsplit(".", 1)
|
|
282
|
-
except ValueError:
|
|
360
|
+
except (ValueError, AttributeError):
|
|
283
361
|
return None
|
|
284
|
-
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)):
|
|
285
364
|
return None
|
|
286
365
|
try:
|
|
287
366
|
payload = json.loads(_b64d(payload_b64))
|
|
@@ -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
|
|
|
@@ -108,23 +108,38 @@ class MemoryCache(Cache):
|
|
|
108
108
|
self._tags.clear()
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
def _dumps(value) -> bytes:
|
|
111
|
+
def _dumps(value, allow_pickle=False) -> bytes:
|
|
112
112
|
try:
|
|
113
113
|
return b"j:" + json.dumps(value).encode("utf-8")
|
|
114
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
|
|
115
118
|
return b"p:" + pickle.dumps(value)
|
|
116
119
|
|
|
117
120
|
|
|
118
|
-
|
|
121
|
+
_MISS = object()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _loads(raw: bytes, allow_pickle=False):
|
|
119
125
|
if raw.startswith(b"j:"):
|
|
120
126
|
return json.loads(raw[2:].decode("utf-8"))
|
|
121
127
|
if raw.startswith(b"p:"):
|
|
122
|
-
|
|
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
|
|
123
130
|
return raw
|
|
124
131
|
|
|
125
132
|
|
|
126
133
|
class RedisCache(Cache):
|
|
127
|
-
|
|
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
|
|
128
143
|
if client is not None:
|
|
129
144
|
self._r = client
|
|
130
145
|
else:
|
|
@@ -145,7 +160,7 @@ class RedisCache(Cache):
|
|
|
145
160
|
return f"{self._prefix}tag:{tag}"
|
|
146
161
|
|
|
147
162
|
def set(self, key, value, ttl=None, tags=()):
|
|
148
|
-
self._r.set(self._k(key), _dumps(value), ex=ttl)
|
|
163
|
+
self._r.set(self._k(key), _dumps(value, self.allow_pickle), ex=ttl)
|
|
149
164
|
for t in tags:
|
|
150
165
|
self._r.sadd(self._t(t), self._k(key))
|
|
151
166
|
|
|
@@ -153,7 +168,8 @@ class RedisCache(Cache):
|
|
|
153
168
|
raw = self._r.get(self._k(key))
|
|
154
169
|
if raw is None:
|
|
155
170
|
return default
|
|
156
|
-
|
|
171
|
+
value = _loads(bytes(raw), self.allow_pickle)
|
|
172
|
+
return default if value is _MISS else value
|
|
157
173
|
|
|
158
174
|
def delete(self, key):
|
|
159
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")
|
|
@@ -364,8 +411,11 @@ def cmd_deploy(args):
|
|
|
364
411
|
files = {}
|
|
365
412
|
if target in ("docker", "compose"):
|
|
366
413
|
files["Dockerfile"] = D.dockerfile(port=args.port)
|
|
414
|
+
domain = getattr(args, "domain", "") or ""
|
|
367
415
|
if target == "compose" or args.compose:
|
|
368
|
-
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)
|
|
369
419
|
elif target == "k8s":
|
|
370
420
|
files["k8s.yaml"] = D.k8s_manifest(app=args.app, image=args.image, port=args.port)
|
|
371
421
|
else:
|
|
@@ -374,6 +424,10 @@ def cmd_deploy(args):
|
|
|
374
424
|
with open(os.path.join(outdir, name), "w") as fh:
|
|
375
425
|
fh.write(body)
|
|
376
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.")
|
|
377
431
|
|
|
378
432
|
|
|
379
433
|
def cmd_dts(args):
|
|
@@ -444,7 +498,9 @@ def main(argv=None):
|
|
|
444
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)
|
|
445
499
|
p = sub.add_parser("mcp", help="run the MCP server (stdio) for AI assistants"); p.set_defaults(fn=cmd_mcp)
|
|
446
500
|
p = sub.add_parser("lsp", help="run the language server (stdio) for editors"); p.set_defaults(fn=cmd_lsp)
|
|
447
|
-
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)
|
|
448
504
|
p = sub.add_parser("dts", help="generate Python stubs from a TypeScript .d.ts file (experimental)")
|
|
449
505
|
p.add_argument("dts"); p.add_argument("-o", "--out", default=None); p.set_defaults(fn=cmd_dts)
|
|
450
506
|
p = sub.add_parser("add", help="add npm packages for browser code (no Node.js needed)")
|
|
@@ -461,6 +517,7 @@ def main(argv=None):
|
|
|
461
517
|
p.add_argument("--port", type=int, default=8000)
|
|
462
518
|
p.add_argument("--compose", action="store_true")
|
|
463
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")
|
|
464
521
|
p.add_argument("--app", default="pyweb")
|
|
465
522
|
p.add_argument("--image", default="pyweb:latest")
|
|
466
523
|
p.set_defaults(fn=cmd_deploy)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Settings from the environment, in one place.
|
|
2
|
+
|
|
3
|
+
Every ``PYWEB_*`` variable the servers read is parsed and checked here.
|
|
4
|
+
``settings()`` reads the environment each time it's called (cheap), so a
|
|
5
|
+
changed variable takes effect without restarting tests or tools; a bad
|
|
6
|
+
value raises ``ValueError`` naming the variable.
|
|
7
|
+
|
|
8
|
+
============================= =========== =====================================================
|
|
9
|
+
Variable Default Meaning
|
|
10
|
+
============================= =========== =====================================================
|
|
11
|
+
``PYWEB_ENV`` development ``production`` requires ``PYWEB_AUTH_SECRET``
|
|
12
|
+
``PYWEB_AUTH_SECRET`` (dev key) root signing secret (sessions, feeds, live queries)
|
|
13
|
+
``PYWEB_AUTH_SECRET_PREVIOUS`` (none) old secrets still accepted, comma-separated
|
|
14
|
+
``PYWEB_CSRF_SECRET`` (none) token CSRF checks for ``@auth_required`` RPCs
|
|
15
|
+
``PYWEB_COOKIE_SECURE`` off ``Secure`` cookies and HSTS (set behind HTTPS)
|
|
16
|
+
``PYWEB_CSP`` built in Content-Security-Policy for pages
|
|
17
|
+
``PYWEB_TRUST_PROXY`` 0 reverse proxies in front (for ``X-Forwarded-For``)
|
|
18
|
+
``PYWEB_REDIS_URL`` (none) share rate limits and live updates across servers
|
|
19
|
+
``PYWEB_MAX_CONNECTIONS`` 256 simultaneous connections (``pyweb serve``)
|
|
20
|
+
``PYWEB_SOCKET_TIMEOUT`` 30 seconds before a stalled client is dropped
|
|
21
|
+
``PYWEB_MAX_STREAMS_PER_CLIENT`` 20 open live connections per client address
|
|
22
|
+
``PYWEB_RENDER_TIMEOUT`` 30 seconds a page may take to render (504 after)
|
|
23
|
+
============================= =========== =====================================================
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import dataclasses
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
32
|
+
_FALSE = ("", "0", "false", "no", "off")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _bool(name, env):
|
|
36
|
+
raw = env.get(name, "").strip().lower()
|
|
37
|
+
if raw in _TRUE:
|
|
38
|
+
return True
|
|
39
|
+
if raw in _FALSE:
|
|
40
|
+
return False
|
|
41
|
+
raise ValueError(f"{name}={env[name]!r}: use 1/true or 0/false")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _int(name, env, default, minimum=1):
|
|
45
|
+
raw = env.get(name, "").strip()
|
|
46
|
+
if not raw:
|
|
47
|
+
return default
|
|
48
|
+
try:
|
|
49
|
+
value = int(raw)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise ValueError(f"{name}={raw!r} isn't a whole number") from None
|
|
52
|
+
if value < minimum:
|
|
53
|
+
raise ValueError(f"{name} must be at least {minimum}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _proxies(env):
|
|
58
|
+
raw = env.get("PYWEB_TRUST_PROXY", "").strip().lower()
|
|
59
|
+
if raw in _FALSE:
|
|
60
|
+
return 0
|
|
61
|
+
if raw in _TRUE:
|
|
62
|
+
return 1
|
|
63
|
+
return _int("PYWEB_TRUST_PROXY", env, 0, minimum=0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclasses.dataclass(frozen=True)
|
|
67
|
+
class Settings:
|
|
68
|
+
env: str = "development"
|
|
69
|
+
auth_secret: str | None = None
|
|
70
|
+
previous_secrets: tuple = ()
|
|
71
|
+
csrf_secret: str | None = None
|
|
72
|
+
cookie_secure: bool = False
|
|
73
|
+
csp: str | None = None
|
|
74
|
+
trust_proxy: int = 0
|
|
75
|
+
redis_url: str | None = None
|
|
76
|
+
max_connections: int = 256
|
|
77
|
+
socket_timeout: int = 30
|
|
78
|
+
max_streams_per_client: int = 20
|
|
79
|
+
render_timeout: int = 30
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def production(self):
|
|
83
|
+
return self.env == "production"
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_env(cls, env=None):
|
|
87
|
+
env = os.environ if env is None else env
|
|
88
|
+
return cls(
|
|
89
|
+
env=env.get("PYWEB_ENV", "development").strip() or "development",
|
|
90
|
+
auth_secret=env.get("PYWEB_AUTH_SECRET") or None,
|
|
91
|
+
previous_secrets=tuple(s.strip() for s in env.get("PYWEB_AUTH_SECRET_PREVIOUS", "").split(",")
|
|
92
|
+
if s.strip()),
|
|
93
|
+
csrf_secret=env.get("PYWEB_CSRF_SECRET") or None,
|
|
94
|
+
cookie_secure=_bool("PYWEB_COOKIE_SECURE", env),
|
|
95
|
+
csp=env.get("PYWEB_CSP") or None,
|
|
96
|
+
trust_proxy=_proxies(env),
|
|
97
|
+
redis_url=env.get("PYWEB_REDIS_URL") or None,
|
|
98
|
+
max_connections=_int("PYWEB_MAX_CONNECTIONS", env, 256),
|
|
99
|
+
socket_timeout=_int("PYWEB_SOCKET_TIMEOUT", env, 30),
|
|
100
|
+
max_streams_per_client=_int("PYWEB_MAX_STREAMS_PER_CLIENT", env, 20),
|
|
101
|
+
render_timeout=_int("PYWEB_RENDER_TIMEOUT", env, 30),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def settings(env=None) -> Settings:
|
|
106
|
+
"""The current settings (read from the environment now)."""
|
|
107
|
+
return Settings.from_env(env)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def startup():
|
|
111
|
+
"""Validate settings when a server starts (a bad value stops it with a clear error) and
|
|
112
|
+
connect shared services: with ``PYWEB_REDIS_URL``, live updates go through Redis so
|
|
113
|
+
every server process sees them."""
|
|
114
|
+
current = settings()
|
|
115
|
+
if current.redis_url:
|
|
116
|
+
from . import realtime
|
|
117
|
+
if type(realtime.current_bus()) is realtime.Bus:
|
|
118
|
+
try:
|
|
119
|
+
realtime.use_bus(realtime.RedisBus(current.redis_url))
|
|
120
|
+
except ImportError:
|
|
121
|
+
pass
|
|
122
|
+
return current
|
|
@@ -171,30 +171,62 @@ def _secret(ctx):
|
|
|
171
171
|
return _dev_secret
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def sign_key(purpose, ctx=None):
|
|
175
|
+
"""The key that signs new ``purpose`` tokens (see :mod:`pyweb.keys`)."""
|
|
176
|
+
from . import keys
|
|
177
|
+
return keys.signing_key(_secret(ctx or current()), purpose)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def verify_keys(purpose, ctx=None):
|
|
181
|
+
"""Every key a ``purpose`` token may carry: current, previous secrets, pre-0.4.4."""
|
|
182
|
+
from . import keys
|
|
183
|
+
return keys.verify_keys(_secret(ctx or current()), purpose)
|
|
184
|
+
|
|
185
|
+
|
|
174
186
|
class _SessionProxy:
|
|
175
187
|
"""``pyweb.session``: signed-cookie login sessions."""
|
|
176
188
|
|
|
189
|
+
#: Logged out after this long without a visit (seconds).
|
|
177
190
|
max_age = 7 * 24 * 3600
|
|
191
|
+
#: Logged out this long after signing in, however active (seconds).
|
|
192
|
+
absolute_age = 30 * 24 * 3600
|
|
193
|
+
#: A visit after this long renews the idle timer (the cookie is reissued).
|
|
194
|
+
renew_after = 24 * 3600
|
|
178
195
|
|
|
179
196
|
def user(self):
|
|
180
197
|
"""The session payload (``{"sub": user_id, ...}``) or ``None``."""
|
|
181
198
|
ctx = current()
|
|
182
199
|
if not ctx._session_loaded:
|
|
183
200
|
from pyweb import auth
|
|
184
|
-
|
|
201
|
+
payload = auth.session_from_request(ctx.request, verify_keys("session", ctx), self.max_age)
|
|
202
|
+
if payload is not None and not auth.session_valid(payload, absolute_age=self.absolute_age):
|
|
203
|
+
payload = None
|
|
204
|
+
ctx._session = payload
|
|
185
205
|
ctx._session_loaded = True
|
|
206
|
+
if payload is not None and auth.time.time() - payload.get("iat", 0) > self.renew_after:
|
|
207
|
+
self._issue(ctx, {k: v for k, v in payload.items() if k != "iat"})
|
|
186
208
|
return ctx._session
|
|
187
209
|
|
|
188
|
-
def
|
|
210
|
+
def _issue(self, ctx, payload):
|
|
189
211
|
from pyweb import auth
|
|
190
|
-
|
|
191
|
-
payload = {"sub": user_id, **claims}
|
|
192
|
-
token = auth.issue_session(payload, _secret(ctx), self.max_age)
|
|
212
|
+
token = auth.issue_session(payload, sign_key("session", ctx), self.max_age)
|
|
193
213
|
request.set_cookie(auth.SESSION_COOKIE, token, max_age=self.max_age)
|
|
194
|
-
ctx._session = auth.verify_session(token,
|
|
214
|
+
ctx._session = auth.verify_session(token, sign_key("session", ctx), self.max_age)
|
|
195
215
|
ctx._session_loaded = True
|
|
196
216
|
return ctx._session
|
|
197
217
|
|
|
218
|
+
def login(self, user_id, **claims):
|
|
219
|
+
"""Sign ``user_id`` in. Each login starts a new session (a fresh ``sid``)."""
|
|
220
|
+
import secrets as _secrets
|
|
221
|
+
import time as _time
|
|
222
|
+
from pyweb import auth
|
|
223
|
+
ctx = current()
|
|
224
|
+
payload = {**claims, "sub": user_id, "sid": _secrets.token_urlsafe(16), "auth_time": int(_time.time())}
|
|
225
|
+
versions = auth.session_versions()
|
|
226
|
+
if versions is not None:
|
|
227
|
+
payload["ver"] = versions.get(user_id)
|
|
228
|
+
return self._issue(ctx, payload)
|
|
229
|
+
|
|
198
230
|
def logout(self):
|
|
199
231
|
from pyweb import auth
|
|
200
232
|
ctx = current()
|
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import queue
|
|
8
8
|
import re
|
|
9
9
|
import threading
|
|
10
|
+
import weakref
|
|
10
11
|
from contextlib import contextmanager
|
|
11
12
|
|
|
12
13
|
_OPS = {
|
|
@@ -253,6 +254,10 @@ class TransientDBError(Exception):
|
|
|
253
254
|
"""Retriable error (deadlock, serialization failure, conn reset)."""
|
|
254
255
|
|
|
255
256
|
|
|
257
|
+
#: Every database opened with connect(), for readiness checks (/readyz).
|
|
258
|
+
OPEN: "weakref.WeakSet" = weakref.WeakSet()
|
|
259
|
+
|
|
260
|
+
|
|
256
261
|
class _PooledDB:
|
|
257
262
|
"""Shared DB-API pool: thread-local transactions, prepared statements,
|
|
258
263
|
streaming cursors, prepared-statement cache, retriable-error mapping.
|
|
@@ -283,6 +288,11 @@ class _PooledDB:
|
|
|
283
288
|
self._local = threading.local()
|
|
284
289
|
self._stmt_cache_size = statement_cache
|
|
285
290
|
self._stmt_cache: dict[str, str] = {}
|
|
291
|
+
OPEN.add(self)
|
|
292
|
+
|
|
293
|
+
def ping(self):
|
|
294
|
+
"""Raise if the database can't answer a trivial query (readiness checks)."""
|
|
295
|
+
self.execute("SELECT 1")
|
|
286
296
|
|
|
287
297
|
def _current(self):
|
|
288
298
|
return getattr(self._local, "conn", None)
|