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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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)