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.
Files changed (122) hide show
  1. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/PKG-INFO +2 -2
  2. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/README.md +1 -1
  3. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyproject.toml +1 -1
  4. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/ai/guide.md +6 -1
  5. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/asgi.py +2 -0
  6. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/auth.py +83 -4
  7. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/build.py +7 -0
  8. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/cache.py +22 -6
  9. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/cli/__init__.py +65 -8
  10. pyweb_stack-0.4.4/pyweb/config.py +122 -0
  11. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/context.py +38 -6
  12. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/db/__init__.py +10 -0
  13. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/deploy.py +45 -2
  14. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/hosting.py +68 -11
  15. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/jobs.py +16 -0
  16. pyweb_stack-0.4.4/pyweb/keys.py +61 -0
  17. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/livedata.py +6 -5
  18. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/packages.py +35 -1
  19. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/realtime.py +19 -3
  20. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/rpc.py +53 -0
  21. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/runtime/server/__init__.py +173 -27
  22. pyweb_stack-0.4.4/pyweb/runtime/server/argcheck.py +194 -0
  23. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/serve.py +76 -15
  24. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/PKG-INFO +2 -2
  25. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/SOURCES.txt +6 -0
  26. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_cli_deploy.py +20 -0
  27. pyweb_stack-0.4.4/tests/test_keys_sessions.py +158 -0
  28. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_packages.py +32 -0
  29. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_production_gaps.py +2 -2
  30. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_realtime_transport.py +3 -1
  31. pyweb_stack-0.4.4/tests/test_request_hardening.py +197 -0
  32. pyweb_stack-0.4.4/tests/test_systems.py +158 -0
  33. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/LICENSE +0 -0
  34. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/__init__.py +0 -0
  35. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/app.py +0 -0
  36. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/app_loader.py +0 -0
  37. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/bench.py +0 -0
  38. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/browser.py +0 -0
  39. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/cli/__main__.py +0 -0
  40. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/__init__.py +0 -0
  41. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/ast.py +0 -0
  42. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/__init__.py +0 -0
  43. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/codegen/ir.py +0 -0
  44. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/errors.py +0 -0
  45. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/lower.py +0 -0
  46. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/parser.py +0 -0
  47. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/pipeline.py +0 -0
  48. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/pyjs.py +0 -0
  49. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/compiler/rpc.py +0 -0
  50. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/css.py +0 -0
  51. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/db/migrate.py +0 -0
  52. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/decorators.py +0 -0
  53. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/dts.py +0 -0
  54. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/forms.py +0 -0
  55. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/livetable.py +0 -0
  56. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/lsp.py +0 -0
  57. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/markdown.py +0 -0
  58. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/mcp.py +0 -0
  59. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/models.py +0 -0
  60. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/observability.py +0 -0
  61. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/platform.py +0 -0
  62. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/plugins.py +0 -0
  63. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/py.typed +0 -0
  64. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/runtime/browser/markdown.js +0 -0
  65. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/runtime/browser/runtime.js +0 -0
  66. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/security.py +0 -0
  67. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/ssr.py +0 -0
  68. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/sync.py +0 -0
  69. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/ai-chat.pyweb +0 -0
  70. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/app.css +0 -0
  71. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/auth.pyweb +0 -0
  72. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/blog.pyweb +0 -0
  73. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/chat.pyweb +0 -0
  74. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/counter.pyweb +0 -0
  75. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/templates/todo.pyweb +0 -0
  76. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/testing.py +0 -0
  77. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb/uploads.py +0 -0
  78. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/dependency_links.txt +0 -0
  79. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/entry_points.txt +0 -0
  80. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/requires.txt +0 -0
  81. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/pyweb_stack.egg-info/top_level.txt +0 -0
  82. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/setup.cfg +0 -0
  83. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth.py +0 -0
  84. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth_contract.py +0 -0
  85. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth_security.py +0 -0
  86. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_auth_v1.py +0 -0
  87. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_backplane.py +0 -0
  88. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_browser_build.py +0 -0
  89. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_budget.py +0 -0
  90. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_codegen.py +0 -0
  91. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_compiler.py +0 -0
  92. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_db_production.py +0 -0
  93. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_deploy_upload_render.py +0 -0
  94. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_docs.py +0 -0
  95. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_dts.py +0 -0
  96. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_e2e.py +0 -0
  97. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_examples.py +0 -0
  98. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_forms_security_obs.py +0 -0
  99. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_jobs_cache_realtime.py +0 -0
  100. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_livedata.py +0 -0
  101. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_lsp.py +0 -0
  102. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_markdown.py +0 -0
  103. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_mcp.py +0 -0
  104. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_models_db.py +0 -0
  105. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_multifile.py +0 -0
  106. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_multipage.py +0 -0
  107. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_observability.py +0 -0
  108. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_parser.py +0 -0
  109. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_parser_v1.py +0 -0
  110. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_plugins_platform_bench.py +0 -0
  111. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_pyjs_semantics.py +0 -0
  112. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_reactivity.py +0 -0
  113. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_reference_apps.py +0 -0
  114. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_rpc_placement.py +0 -0
  115. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_rpc_production.py +0 -0
  116. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_runtime_signals.py +0 -0
  117. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_server_db.py +0 -0
  118. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_static_hashing.py +0 -0
  119. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_static_serving.py +0 -0
  120. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_streaming.py +0 -0
  121. {pyweb_stack-0.4.3 → pyweb_stack-0.4.4}/tests/test_sync_live.py +0 -0
  122. {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
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.3"
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
- def verify_session(cookie: str, secret: str, max_age: int = 3600):
279
- """Verify a stateless session cookie; return payload dict or ``None``."""
278
+ class SessionVersions:
279
+ """Per-user session versions, for signing a user out everywhere (in memory: one process).
280
+
281
+ Sessions carry the version current when they were made; :func:`revoke_user`
282
+ bumps it, so every older session stops working.
283
+ """
284
+
285
+ def __init__(self):
286
+ self._v: dict = {}
287
+ self._lock = __import__("threading").Lock()
288
+
289
+ def get(self, user_id) -> int:
290
+ with self._lock:
291
+ return self._v.get(str(user_id), 0)
292
+
293
+ def bump(self, user_id) -> int:
294
+ with self._lock:
295
+ self._v[str(user_id)] = self._v.get(str(user_id), 0) + 1
296
+ return self._v[str(user_id)]
297
+
298
+
299
+ class RedisSessionVersions(SessionVersions):
300
+ """Session versions in Redis, shared by every server process."""
301
+
302
+ def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:sessver:"):
303
+ super().__init__()
304
+ if client is None:
305
+ import redis # noqa: PLC0415 - optional dependency
306
+ client = redis.Redis.from_url(url)
307
+ self._r, self._prefix = client, prefix
308
+
309
+ def get(self, user_id) -> int:
310
+ return int(self._r.get(self._prefix + str(user_id)) or 0)
311
+
312
+ def bump(self, user_id) -> int:
313
+ return int(self._r.incr(self._prefix + str(user_id)))
314
+
315
+
316
+ _versions: SessionVersions | None = None
317
+
318
+
319
+ def use_session_versions(store):
320
+ """Turn on revocation with ``store`` (a :class:`SessionVersions`); returns it."""
321
+ global _versions
322
+ _versions = store
323
+ return store
324
+
325
+
326
+ def session_versions():
327
+ return _versions
328
+
329
+
330
+ def revoke_user(user_id):
331
+ """Sign ``user_id`` out of every session (every device). Turns revocation on if needed."""
332
+ store = _versions if _versions is not None else use_session_versions(SessionVersions())
333
+ return store.bump(user_id)
334
+
335
+
336
+ #: Sessions end this long after sign-in, however active the user is.
337
+ ABSOLUTE_AGE = 30 * 24 * 3600
338
+
339
+
340
+ def session_valid(payload, *, absolute_age=ABSOLUTE_AGE, now=None) -> bool:
341
+ """Checks beyond the signature: the absolute lifetime and revocation."""
342
+ now = time.time() if now is None else now
343
+ started = payload.get("auth_time", payload.get("iat", 0))
344
+ if not isinstance(started, (int, float)) or now - started > absolute_age:
345
+ return False
346
+ if _versions is not None and "sub" in payload:
347
+ if int(payload.get("ver", 0) or 0) < _versions.get(payload["sub"]):
348
+ return False
349
+ return True
350
+
351
+
352
+ def verify_session(cookie: str, secret, max_age: int = 3600):
353
+ """Verify a stateless session cookie; return payload dict or ``None``.
354
+
355
+ ``secret`` may be a list of keys (key rotation): any of them may have signed it.
356
+ """
357
+ from .keys import any_valid
280
358
  try:
281
359
  payload_b64, sig = cookie.rsplit(".", 1)
282
- except ValueError:
360
+ except (ValueError, AttributeError):
283
361
  return None
284
- if not hmac.compare_digest(_sign(payload_b64, secret), sig):
362
+ candidates = secret if isinstance(secret, (list, tuple)) else [secret]
363
+ if not any_valid(candidates, lambda k: hmac.compare_digest(_sign(payload_b64, k), sig)):
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
- def _loads(raw: bytes):
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
- return pickle.loads(raw[2:])
128
+ # Unpickling runs code chosen by whoever wrote the bytes: only with explicit trust.
129
+ return pickle.loads(raw[2:]) if allow_pickle else _MISS # noqa: S301
123
130
  return raw
124
131
 
125
132
 
126
133
  class RedisCache(Cache):
127
- def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:"):
134
+ """A cache in Redis, shared by every server process.
135
+
136
+ Values are stored as JSON. ``allow_pickle=True`` also stores other Python
137
+ objects with pickle; only use it when nobody else can write to your Redis,
138
+ because reading a pickle can run arbitrary code.
139
+ """
140
+
141
+ def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:", allow_pickle=False):
142
+ self.allow_pickle = allow_pickle
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
- return _loads(bytes(raw))
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
- if any(f["kind"] in ("secret-leak", "compile-error") for f in findings):
55
+ problems = production_problems(src, os.path.dirname(os.path.abspath(args.file))) \
56
+ if getattr(args, "production", False) else []
57
+ for p in problems:
58
+ print(f"production: {p}")
59
+ if getattr(args, "production", False) and not problems:
60
+ print("production: ready")
61
+ if problems or any(f["kind"] in ("secret-leak", "compile-error") for f in findings):
57
62
  raise SystemExit(1)
58
63
 
59
64
 
65
+ def production_problems(source="", app_dir="."):
66
+ """What would make this app unsafe or unreliable in production, from the environment and the app."""
67
+ from pyweb import config
68
+ out = []
69
+ try:
70
+ s = config.settings()
71
+ except ValueError as exc:
72
+ return [str(exc)]
73
+ if not s.auth_secret:
74
+ out.append("PYWEB_AUTH_SECRET isn't set: sessions would use a development key "
75
+ "(generate one: python -c \"import secrets; print(secrets.token_hex(32))\")")
76
+ elif len(s.auth_secret) < 32:
77
+ out.append("PYWEB_AUTH_SECRET is shorter than 32 characters; use a long random value")
78
+ if not s.production:
79
+ out.append("PYWEB_ENV isn't 'production' (it makes a missing secret an error instead of a fallback)")
80
+ if not s.cookie_secure:
81
+ out.append("PYWEB_COOKIE_SECURE isn't on: cookies would also travel over plain HTTP "
82
+ "(set it when you serve over HTTPS, which you should)")
83
+ if not s.trust_proxy:
84
+ out.append("PYWEB_TRUST_PROXY isn't set: behind nginx, Caddy or a load balancer every visitor "
85
+ "looks like the proxy to rate limits (set it to 1, or the number of proxies; "
86
+ "ignore this if clients connect directly)")
87
+ try:
88
+ workers = int(os.environ.get("WEB_CONCURRENCY", "1") or 1)
89
+ except ValueError:
90
+ workers = 1
91
+ if workers > 1 and not s.redis_url:
92
+ out.append(f"WEB_CONCURRENCY={workers} without PYWEB_REDIS_URL: each worker would keep its own rate "
93
+ "limits and live updates (connect Redis to share them)")
94
+ if "allow_pickle=True" in (source or ""):
95
+ out.append("RedisCache(allow_pickle=True): anyone who can write to Redis could run code; store JSON")
96
+ if os.path.isfile(os.path.join(app_dir, "pyweb.lock")):
97
+ from pyweb import packages
98
+ for path, why in packages.verify(app_dir)[:5]:
99
+ out.append(f"{path}: {why} (run pyweb add to reinstall)")
100
+ return out
101
+
102
+
60
103
  def cmd_build(args):
61
104
  from pyweb.compiler import compile_source
62
105
  from pyweb.build import build as _build
63
106
  src = _load(args.file)
64
107
  out = compile_source(src, filename=args.file)
65
108
  production = getattr(args, "production", False)
66
- manifest = _build(out, args.out, source=src, production=production,
67
- app_dir=os.path.dirname(os.path.abspath(args.file)))
109
+ try:
110
+ manifest = _build(out, args.out, source=src, production=production,
111
+ app_dir=os.path.dirname(os.path.abspath(args.file)))
112
+ except RuntimeError as exc:
113
+ print(f"build failed: {exc}", file=sys.stderr)
114
+ raise SystemExit(1) from None
68
115
  mode = " (production)" if production else ""
69
116
  print(f"built {len(manifest['pages'])} page(s) + {len(out['rpc'])} rpc(s) -> {args.out}/{mode}")
70
117
  from pathlib import Path as _Path
@@ -137,14 +184,14 @@ def _dev_load(state, Site):
137
184
  def cmd_dev(args):
138
185
  """Development server: compile on save, live reload, error overlay."""
139
186
  from pyweb.hosting import Site
140
- from pyweb.serve import ThreadedServer
187
+ from pyweb.serve import LimitedHandler, ThreadedServer
141
188
 
142
189
  state = _DevState(args.file)
143
190
  _dev_load(state, Site)
144
191
  if state.error is not None:
145
192
  print(f"error: {state.error}", file=sys.stderr)
146
193
 
147
- class H(http.server.BaseHTTPRequestHandler):
194
+ class H(LimitedHandler):
148
195
  def _send(self, status, headers, body):
149
196
  from pyweb.hosting import write_http
150
197
  write_http(self, status, headers, body, head=self.command == "HEAD")
@@ -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"); p.set_defaults(fn=cmd_check)
501
+ p = sub.add_parser("check"); p.add_argument("file")
502
+ p.add_argument("--production", action="store_true", help="also check the environment is ready for production")
503
+ p.set_defaults(fn=cmd_check)
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
- ctx._session = auth.session_from_request(ctx.request, _secret(ctx), self.max_age)
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 login(self, user_id, **claims):
210
+ def _issue(self, ctx, payload):
189
211
  from pyweb import auth
190
- ctx = current()
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, _secret(ctx), self.max_age)
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)