pyweb-stack 0.1.0__py3-none-any.whl
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/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/rpc.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Production RPC protocol: error codes, tracing, retries, rate limits.
|
|
2
|
+
|
|
3
|
+
Wire format (unchanged from prototype, extended)::
|
|
4
|
+
|
|
5
|
+
POST /__pyweb/rpc/<name>
|
|
6
|
+
Headers: Content-Type: application/json
|
|
7
|
+
X-Request-Id (optional, echoed back)
|
|
8
|
+
traceparent (optional W3C trace context, echoed/propagated)
|
|
9
|
+
X-CSRF-Token (required for cookie-authed calls to protected RPCs)
|
|
10
|
+
Authorization: Bearer <session> (or pyweb_session cookie)
|
|
11
|
+
Body: {"args": {...}}
|
|
12
|
+
|
|
13
|
+
Success: 200 {"result": ...}
|
|
14
|
+
Stream: 200 application/x-ndjson, one {"chunk": ...} per line
|
|
15
|
+
Error: <status> {"error": {"code": ..., "message": ...}}
|
|
16
|
+
|
|
17
|
+
Why this exists: automatic RPC must feel local *without* hiding failure
|
|
18
|
+
semantics. Every failure maps to a stable machine-readable ``code`` plus
|
|
19
|
+
an HTTP status, so browser code, retries, and observability can react
|
|
20
|
+
correctly. Alternatives considered: gRPC/Connect (heavier, worse browser
|
|
21
|
+
story), tRPC-style (JS-only). Plain JSON-over-HTTP keeps ``curl``,
|
|
22
|
+
proxies, and edge caches working.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import random
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Code:
|
|
34
|
+
VALIDATION = "validation_error" # 422 — bad args
|
|
35
|
+
AUTH = "unauthenticated" # 401 — no/invalid session
|
|
36
|
+
FORBIDDEN = "forbidden" # 403 — missing role/permission
|
|
37
|
+
NOT_FOUND = "not_found" # 404 — unknown rpc
|
|
38
|
+
RATE_LIMIT = "rate_limited" # 429 — slow down
|
|
39
|
+
TIMEOUT = "timeout" # 504 — fn exceeded rpc_timeout
|
|
40
|
+
CONFLICT = "conflict" # 409 — optimistic-lock mismatch
|
|
41
|
+
CSRF = "csrf_failed" # 403 — bad/missing CSRF token
|
|
42
|
+
INTERNAL = "internal" # 500 — anything else
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_STATUS = {
|
|
46
|
+
Code.VALIDATION: 422, Code.AUTH: 401, Code.FORBIDDEN: 403,
|
|
47
|
+
Code.NOT_FOUND: 404, Code.RATE_LIMIT: 429, Code.TIMEOUT: 504,
|
|
48
|
+
Code.CONFLICT: 409, Code.CSRF: 403, Code.INTERNAL: 500,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RPCError(Exception):
|
|
53
|
+
"""Raised by server functions to return a typed cross-boundary error."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, code: str = Code.INTERNAL, message: str = "",
|
|
56
|
+
*, status: int | None = None, details: dict | None = None):
|
|
57
|
+
super().__init__(message or code)
|
|
58
|
+
self.code = code
|
|
59
|
+
self.status = status if status is not None else _STATUS.get(code, 500)
|
|
60
|
+
self.details = details or {}
|
|
61
|
+
|
|
62
|
+
def to_dict(self):
|
|
63
|
+
return {"code": self.code, "message": str(self),
|
|
64
|
+
"details": self.details}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def status_for(code: str) -> int:
|
|
68
|
+
return _STATUS.get(code, 500)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def new_request_id() -> str:
|
|
72
|
+
return uuid.uuid4().hex[:12]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_traceparent(value: str | None) -> tuple[str, str] | None:
|
|
76
|
+
"""Parse W3C ``traceparent`` into (trace_id, parent_span_id)."""
|
|
77
|
+
try:
|
|
78
|
+
parts = (value or "").split("-")
|
|
79
|
+
if len(parts) >= 3 and len(parts[1]) == 32 and len(parts[2]) == 16:
|
|
80
|
+
return parts[1], parts[2]
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_traceparent(trace_id: str, span_id: str) -> str:
|
|
87
|
+
return f"00-{trace_id}-{span_id}-01"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RateLimiter:
|
|
91
|
+
"""Fixed-window per-key rate limiter (memory; Redis adapter optional)."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, max_calls: int = 60, window: float = 60.0,
|
|
94
|
+
time_fn=None):
|
|
95
|
+
self.max_calls = max_calls
|
|
96
|
+
self.window = window
|
|
97
|
+
self._now = time_fn or time.monotonic
|
|
98
|
+
self._hits: dict[str, list[float]] = {}
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
|
|
101
|
+
def allow(self, key: str) -> tuple[bool, int]:
|
|
102
|
+
"""Return (allowed, retry_after_seconds)."""
|
|
103
|
+
now = self._now()
|
|
104
|
+
with self._lock:
|
|
105
|
+
hits = [t for t in self._hits.get(key, []) if now - t < self.window]
|
|
106
|
+
if len(hits) >= self.max_calls:
|
|
107
|
+
retry = int(self.window - (now - hits[0])) + 1
|
|
108
|
+
self._hits[key] = hits
|
|
109
|
+
return False, max(retry, 1)
|
|
110
|
+
hits.append(now)
|
|
111
|
+
self._hits[key] = hits
|
|
112
|
+
return True, 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RetryPolicy:
|
|
116
|
+
"""Client retry policy: which statuses are safe to retry + backoff."""
|
|
117
|
+
|
|
118
|
+
RETRYABLE = {502, 503, 504}
|
|
119
|
+
|
|
120
|
+
def __init__(self, attempts: int = 3, base_delay: float = 0.1,
|
|
121
|
+
max_delay: float = 2.0, jitter: float = 0.1):
|
|
122
|
+
self.attempts = max(1, attempts)
|
|
123
|
+
self.base_delay = base_delay
|
|
124
|
+
self.max_delay = max_delay
|
|
125
|
+
self.jitter = jitter
|
|
126
|
+
|
|
127
|
+
def delay(self, attempt: int) -> float:
|
|
128
|
+
d = min(self.base_delay * (2 ** attempt), self.max_delay)
|
|
129
|
+
return d + random.uniform(0, self.jitter)
|
|
130
|
+
|
|
131
|
+
def should_retry(self, attempt: int, status: int | None,
|
|
132
|
+
exc: Exception | None = None) -> bool:
|
|
133
|
+
if attempt + 1 >= self.attempts:
|
|
134
|
+
return False
|
|
135
|
+
if exc is not None:
|
|
136
|
+
return True # network-level failure: safe to retry idempotent reads
|
|
137
|
+
return status in self.RETRYABLE
|