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/compiler/rpc.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Automatic RPC: @server/@worker/@edge functions → endpoints + stubs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_rpc(node):
|
|
9
|
+
for d in node.decorator_list:
|
|
10
|
+
name = ast.unparse(d) if hasattr(ast, "unparse") else ""
|
|
11
|
+
if any(k in name for k in ("server", "worker", "edge", "task")):
|
|
12
|
+
return True
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def rpc_specs(tree):
|
|
17
|
+
specs = []
|
|
18
|
+
for node in tree.body:
|
|
19
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and is_rpc(node):
|
|
20
|
+
args = []
|
|
21
|
+
for a in node.args.args:
|
|
22
|
+
ann = ast.unparse(a.annotation) if (hasattr(ast, "unparse") and a.annotation) else "Any"
|
|
23
|
+
args.append({"name": a.arg, "type": ann})
|
|
24
|
+
ret = ast.unparse(node.returns) if (hasattr(ast, "unparse") and node.returns) else "Any"
|
|
25
|
+
loc = "server"
|
|
26
|
+
for d in node.decorator_list:
|
|
27
|
+
name = ast.unparse(d) if hasattr(ast, "unparse") else ""
|
|
28
|
+
if "worker" in name:
|
|
29
|
+
loc = "worker"
|
|
30
|
+
elif "edge" in name:
|
|
31
|
+
loc = "edge"
|
|
32
|
+
specs.append({"name": node.name, "args": args, "returns": ret,
|
|
33
|
+
"location": loc, "line": node.lineno,
|
|
34
|
+
"async": isinstance(node, ast.AsyncFunctionDef)})
|
|
35
|
+
return specs
|
pyweb/context.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Per-request context for server code: ``request``, ``session``, ``redirect``.
|
|
2
|
+
|
|
3
|
+
Server functions and page bodies run inside a request. They can read it
|
|
4
|
+
and influence the response without threading a request object through
|
|
5
|
+
every call::
|
|
6
|
+
|
|
7
|
+
from pyweb import request, session, redirect
|
|
8
|
+
|
|
9
|
+
@server
|
|
10
|
+
def login(email: str, password: str) -> bool:
|
|
11
|
+
user = find_user(email)
|
|
12
|
+
if user and verify_password(password, user["hash"]):
|
|
13
|
+
session.login(user["id"], roles=user["roles"])
|
|
14
|
+
return True
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
@app.page("/admin")
|
|
18
|
+
def Admin():
|
|
19
|
+
if not session.user():
|
|
20
|
+
return redirect("/login")
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
The context is a :class:`contextvars.ContextVar`, so it is correct under
|
|
24
|
+
threads (``pyweb serve``) and asyncio (ASGI) alike.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextvars
|
|
30
|
+
import os
|
|
31
|
+
import urllib.parse
|
|
32
|
+
|
|
33
|
+
_current = contextvars.ContextVar("pyweb_request", default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _derive_dev_secret():
|
|
37
|
+
# Development only: stable across worker processes of the same project so
|
|
38
|
+
# sessions survive reloads and multi-worker dev servers. Production must
|
|
39
|
+
# set PYWEB_AUTH_SECRET (enforced when PYWEB_ENV=production).
|
|
40
|
+
import getpass
|
|
41
|
+
import hashlib
|
|
42
|
+
import socket
|
|
43
|
+
seed = f"pyweb-dev|{socket.gethostname()}|{os.getcwd()}|{getpass.getuser()}"
|
|
44
|
+
return hashlib.sha256(seed.encode()).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_dev_secret = _derive_dev_secret()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Redirect:
|
|
51
|
+
"""Return ``redirect(url)`` from a page to answer with a redirect."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, url, status=303):
|
|
54
|
+
self.url = url
|
|
55
|
+
self.status = status
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def redirect(url, status=303):
|
|
59
|
+
return Redirect(url, status)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NotFound(Exception):
|
|
63
|
+
"""Raise from a page (or route param conversion) to answer 404."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RequestContext:
|
|
67
|
+
def __init__(self, request, *, auth_secret=None, secure_cookies=False):
|
|
68
|
+
self.request = request
|
|
69
|
+
self.auth_secret = auth_secret
|
|
70
|
+
self.secure_cookies = secure_cookies
|
|
71
|
+
self.set_cookies = [] # raw Set-Cookie header values
|
|
72
|
+
self._session = None
|
|
73
|
+
self._session_loaded = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def current():
|
|
77
|
+
ctx = _current.get()
|
|
78
|
+
if ctx is None:
|
|
79
|
+
raise RuntimeError("no active request: request/session are only available while "
|
|
80
|
+
"PyWeb is handling a page or RPC call")
|
|
81
|
+
return ctx
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def activate(ctx):
|
|
85
|
+
return _current.set(ctx)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def deactivate(token):
|
|
89
|
+
_current.reset(token)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _RequestProxy:
|
|
93
|
+
"""``pyweb.request``: the current HTTP request (read-only)."""
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def path(self):
|
|
97
|
+
return current().request.path.split("?")[0]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def method(self):
|
|
101
|
+
return current().request.method
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def headers(self):
|
|
105
|
+
return dict(current().request.headers or {})
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def cookies(self):
|
|
109
|
+
return dict(current().request.cookies or {})
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def query(self):
|
|
113
|
+
q = current().request.path.partition("?")[2]
|
|
114
|
+
return {k: v[-1] for k, v in urllib.parse.parse_qs(q).items()}
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def id(self):
|
|
118
|
+
return current().request.id
|
|
119
|
+
|
|
120
|
+
def set_cookie(self, name, value, *, max_age=None, path="/", http_only=True,
|
|
121
|
+
same_site="Lax", secure=None):
|
|
122
|
+
ctx = current()
|
|
123
|
+
secure = ctx.secure_cookies if secure is None else secure
|
|
124
|
+
parts = [f"{name}={value}", f"Path={path}", f"SameSite={same_site}"]
|
|
125
|
+
if max_age is not None:
|
|
126
|
+
parts.append(f"Max-Age={int(max_age)}")
|
|
127
|
+
if http_only:
|
|
128
|
+
parts.append("HttpOnly")
|
|
129
|
+
if secure:
|
|
130
|
+
parts.append("Secure")
|
|
131
|
+
ctx.set_cookies.append("; ".join(parts))
|
|
132
|
+
|
|
133
|
+
def __repr__(self):
|
|
134
|
+
try:
|
|
135
|
+
return f"<pyweb.request {self.method} {self.path}>"
|
|
136
|
+
except RuntimeError:
|
|
137
|
+
return "<pyweb.request (no active request)>"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _secret(ctx):
|
|
141
|
+
secret = ctx.auth_secret or os.environ.get("PYWEB_AUTH_SECRET")
|
|
142
|
+
if secret:
|
|
143
|
+
return secret
|
|
144
|
+
if os.environ.get("PYWEB_ENV", "development") == "production":
|
|
145
|
+
raise RuntimeError("PYWEB_AUTH_SECRET must be set in production to use sessions")
|
|
146
|
+
return _dev_secret
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _SessionProxy:
|
|
150
|
+
"""``pyweb.session``: signed-cookie login sessions."""
|
|
151
|
+
|
|
152
|
+
max_age = 7 * 24 * 3600
|
|
153
|
+
|
|
154
|
+
def user(self):
|
|
155
|
+
"""The session payload (``{"sub": user_id, ...}``) or ``None``."""
|
|
156
|
+
ctx = current()
|
|
157
|
+
if not ctx._session_loaded:
|
|
158
|
+
from pyweb import auth
|
|
159
|
+
ctx._session = auth.session_from_request(ctx.request, _secret(ctx), self.max_age)
|
|
160
|
+
ctx._session_loaded = True
|
|
161
|
+
return ctx._session
|
|
162
|
+
|
|
163
|
+
def login(self, user_id, **claims):
|
|
164
|
+
from pyweb import auth
|
|
165
|
+
ctx = current()
|
|
166
|
+
payload = {"sub": user_id, **claims}
|
|
167
|
+
token = auth.issue_session(payload, _secret(ctx), self.max_age)
|
|
168
|
+
request.set_cookie(auth.SESSION_COOKIE, token, max_age=self.max_age)
|
|
169
|
+
ctx._session = auth.verify_session(token, _secret(ctx), self.max_age)
|
|
170
|
+
ctx._session_loaded = True
|
|
171
|
+
return ctx._session
|
|
172
|
+
|
|
173
|
+
def logout(self):
|
|
174
|
+
from pyweb import auth
|
|
175
|
+
ctx = current()
|
|
176
|
+
request.set_cookie(auth.SESSION_COOKIE, "", max_age=0)
|
|
177
|
+
ctx._session = None
|
|
178
|
+
ctx._session_loaded = True
|
|
179
|
+
|
|
180
|
+
def require(self, *roles):
|
|
181
|
+
"""Return the session or raise ``RPCError(unauthenticated/forbidden)``."""
|
|
182
|
+
from pyweb.rpc import Code, RPCError
|
|
183
|
+
user = self.user()
|
|
184
|
+
if not user:
|
|
185
|
+
raise RPCError(Code.AUTH, "login required")
|
|
186
|
+
missing = [r for r in roles if r not in (user.get("roles") or [])]
|
|
187
|
+
if missing:
|
|
188
|
+
raise RPCError(Code.FORBIDDEN, f"missing role(s): {', '.join(missing)}")
|
|
189
|
+
return user
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
request = _RequestProxy()
|
|
193
|
+
session = _SessionProxy()
|
pyweb/css.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Styling pipeline: scoped modules, tokens, dict CSS, Tailwind passthrough.
|
|
2
|
+
|
|
3
|
+
Four levels, no lock-in — plain ``.css`` files keep working untouched:
|
|
4
|
+
|
|
5
|
+
1. ``css({...})`` — Python dict -> scoped class name + stylesheet text.
|
|
6
|
+
2. :class:`Tokens` — design tokens -> ``:root`` CSS variables.
|
|
7
|
+
3. :func:`module` — ``.module.css``-style scoping: rewrites selectors
|
|
8
|
+
with a content hash so component styles never leak.
|
|
9
|
+
4. Tailwind — ``tw("...")`` marks utility strings as intentional so the
|
|
10
|
+
compiler preserves them verbatim and the build can run the Tailwind
|
|
11
|
+
CLI over emitted HTML when configured.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
__all__ = ["css", "Tokens", "module", "tw", "extract_styles",
|
|
20
|
+
"is_tailwind", "TAILWIND_MARK"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hash(text: str, n=6) -> str:
|
|
24
|
+
return hashlib.sha256(text.encode()).hexdigest()[:n]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def css(rules: dict, *, name="c") -> tuple:
|
|
28
|
+
"""``css({"padding": 12, "border_radius": 8})`` -> ``(cls, stylesheet)``.
|
|
29
|
+
|
|
30
|
+
Underscores become hyphens; bare numbers become ``px`` (except for
|
|
31
|
+
unitless properties like ``z_index``, ``opacity``, ``flex``).
|
|
32
|
+
"""
|
|
33
|
+
_UNIT_LESS = {"z-index", "opacity", "flex", "flex-grow", "flex-shrink",
|
|
34
|
+
"order", "font-weight", "line-height", "zoom"}
|
|
35
|
+
decls = []
|
|
36
|
+
for prop, value in rules.items():
|
|
37
|
+
css_prop = prop.replace("_", "-")
|
|
38
|
+
if isinstance(value, (int, float)) and css_prop not in _UNIT_LESS:
|
|
39
|
+
value = f"{value}px"
|
|
40
|
+
decls.append(f"{css_prop}:{value}")
|
|
41
|
+
cls = f"{name}_{_hash(repr(sorted(rules.items())))}"
|
|
42
|
+
return cls, f".{cls}{{{';'.join(decls)}}}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Tokens:
|
|
46
|
+
"""Design tokens -> ``:root`` variables + typed Python access.
|
|
47
|
+
|
|
48
|
+
``Tokens(primary="#635bff").var("primary")`` -> ``"var(--primary)"``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, **tokens):
|
|
52
|
+
self._tokens = tokens
|
|
53
|
+
|
|
54
|
+
def __getattr__(self, name):
|
|
55
|
+
if name.startswith("_"):
|
|
56
|
+
raise AttributeError(name)
|
|
57
|
+
try:
|
|
58
|
+
return self._tokens[name]
|
|
59
|
+
except KeyError:
|
|
60
|
+
raise AttributeError(f"unknown token {name!r}") from None
|
|
61
|
+
|
|
62
|
+
def var(self, name):
|
|
63
|
+
if name not in self._tokens:
|
|
64
|
+
raise KeyError(f"unknown token {name!r}")
|
|
65
|
+
return f"var(--{name.replace('_', '-')})"
|
|
66
|
+
|
|
67
|
+
def stylesheet(self) -> str:
|
|
68
|
+
decls = ";".join(f"--{k.replace('_', '-')}:{v}"
|
|
69
|
+
for k, v in self._tokens.items())
|
|
70
|
+
return f":root{{{decls}}}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def module(css_text: str, *, namespace="m") -> tuple:
|
|
74
|
+
"""Scope a stylesheet: ``.btn`` -> ``.btn_<hash>``.
|
|
75
|
+
|
|
76
|
+
Returns ``(mapping, scoped_css)`` where mapping is
|
|
77
|
+
``{"btn": "btn_<hash>"}`` for use as ``class_={m['btn']}``.
|
|
78
|
+
Element selectors (``h1``, ``article``) and at-rules pass through
|
|
79
|
+
untouched — only class selectors are namespaced.
|
|
80
|
+
"""
|
|
81
|
+
digest = _hash(css_text)
|
|
82
|
+
classes = sorted(set(re.findall(r"\.([A-Za-z_][\w-]*)", css_text)))
|
|
83
|
+
mapping = {c: f"{c}_{namespace}_{digest}" for c in classes}
|
|
84
|
+
|
|
85
|
+
def _repl(match):
|
|
86
|
+
return "." + mapping[match.group(1)]
|
|
87
|
+
|
|
88
|
+
scoped = re.sub(r"\.([A-Za-z_][\w-]*)", _repl, css_text)
|
|
89
|
+
return mapping, scoped
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
TAILWIND_MARK = "tw:"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def tw(utilities: str) -> str:
|
|
96
|
+
"""Mark a Tailwind utility string as intentional (``tw:` prefix)."""
|
|
97
|
+
return f"{TAILWIND_MARK}{utilities}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_tailwind(value: str) -> bool:
|
|
101
|
+
return isinstance(value, str) and value.startswith(TAILWIND_MARK)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_STYLE_RE = re.compile(r"<style>(.*?)</style>", re.S)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_styles(html: str) -> tuple:
|
|
108
|
+
"""Split ``<style>`` blocks out of SSR HTML -> ``(html, css)``.
|
|
109
|
+
|
|
110
|
+
The build writes extracted CSS to hashed ``.css`` files so pages
|
|
111
|
+
stream HTML without duplicated inline styles.
|
|
112
|
+
"""
|
|
113
|
+
parts = _STYLE_RE.findall(html)
|
|
114
|
+
clean = _STYLE_RE.sub("", html)
|
|
115
|
+
return clean, "\n".join(parts)
|