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/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""PyWeb — Python from browser to database."""
|
|
2
|
+
|
|
3
|
+
from .app import App
|
|
4
|
+
from .models import Email, Model
|
|
5
|
+
from . import auth, cache, jobs, realtime, security, observability, forms, testing
|
|
6
|
+
from . import sync, deploy, uploads, lsp, live
|
|
7
|
+
from . import browser as browser_api
|
|
8
|
+
from . import build, css, plugins, platform
|
|
9
|
+
from .jobs import task
|
|
10
|
+
from .context import NotFound, redirect, request, session
|
|
11
|
+
from .rpc import RPCError
|
|
12
|
+
from .decorators import component, edge, server, worker
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"App",
|
|
16
|
+
"request",
|
|
17
|
+
"session",
|
|
18
|
+
"redirect",
|
|
19
|
+
"NotFound",
|
|
20
|
+
"RPCError",
|
|
21
|
+
"component",
|
|
22
|
+
"edge",
|
|
23
|
+
"server",
|
|
24
|
+
"worker",
|
|
25
|
+
"Email",
|
|
26
|
+
"Model",
|
|
27
|
+
"task",
|
|
28
|
+
"auth",
|
|
29
|
+
"cache",
|
|
30
|
+
"jobs",
|
|
31
|
+
"realtime",
|
|
32
|
+
"security",
|
|
33
|
+
"observability",
|
|
34
|
+
"forms",
|
|
35
|
+
"testing",
|
|
36
|
+
"sync",
|
|
37
|
+
"live",
|
|
38
|
+
"deploy",
|
|
39
|
+
"uploads",
|
|
40
|
+
"lsp",
|
|
41
|
+
"browser_api",
|
|
42
|
+
"build",
|
|
43
|
+
"css",
|
|
44
|
+
"plugins",
|
|
45
|
+
"platform",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def _version():
|
|
49
|
+
"""Single source of truth: installed dist metadata, else pyproject."""
|
|
50
|
+
try:
|
|
51
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
52
|
+
try:
|
|
53
|
+
return version("pyweb-stack")
|
|
54
|
+
except PackageNotFoundError:
|
|
55
|
+
pass
|
|
56
|
+
except ImportError: # pragma: no cover - ancient Python
|
|
57
|
+
pass
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
61
|
+
try:
|
|
62
|
+
with open(os.path.join(root, "pyproject.toml")) as fh:
|
|
63
|
+
m = re.search(r'^version\s*=\s*"([^"]+)"', fh.read(), re.M)
|
|
64
|
+
if m:
|
|
65
|
+
return m.group(1)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
return "0.0.0+unknown"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__version__ = _version()
|
pyweb/app.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Application object: page registry and configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class App:
|
|
7
|
+
def __init__(self, database=None, cache=None, auth=None, plugins=(), *,
|
|
8
|
+
title="PyWeb", stylesheets=(), lang="en"):
|
|
9
|
+
self.title = title
|
|
10
|
+
self.stylesheets = list(stylesheets)
|
|
11
|
+
self.lang = lang
|
|
12
|
+
self.database = database
|
|
13
|
+
self.cache = cache
|
|
14
|
+
self.auth = auth
|
|
15
|
+
self.pages: list[tuple[str, str, dict]] = []
|
|
16
|
+
from pyweb.plugins import Registry
|
|
17
|
+
self.plugins = Registry()
|
|
18
|
+
for plugin in plugins:
|
|
19
|
+
self.use(plugin)
|
|
20
|
+
|
|
21
|
+
def page(self, route, *, title=None, render="server"):
|
|
22
|
+
def deco(fn):
|
|
23
|
+
self.pages.append((route, fn.__name__, {"render": render, "title": title}))
|
|
24
|
+
fn.__pyweb_route__ = route
|
|
25
|
+
fn.__pyweb_title__ = title
|
|
26
|
+
fn.__pyweb_render__ = render
|
|
27
|
+
return fn
|
|
28
|
+
|
|
29
|
+
return deco
|
|
30
|
+
|
|
31
|
+
def use(self, plugin):
|
|
32
|
+
"""Attach a :class:`pyweb.plugins.Plugin`; merges its routes."""
|
|
33
|
+
self.plugins.add(plugin)
|
|
34
|
+
for route, fn in plugin.routes:
|
|
35
|
+
self.pages.append((route, fn.__name__, {"render": "server",
|
|
36
|
+
"plugin": plugin.name}))
|
|
37
|
+
return plugin
|
pyweb/app_loader.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Load a `.pyweb` app for serving: compile, execute the Python half,
|
|
2
|
+
wire ``@server`` functions to RPC, and render pages per request.
|
|
3
|
+
|
|
4
|
+
``pyweb dev``, ``pyweb serve`` and the ASGI adapter all go through
|
|
5
|
+
:class:`LoadedApp`, so the three behave identically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import copy
|
|
12
|
+
import inspect
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import types
|
|
16
|
+
|
|
17
|
+
from .compiler import compile_source
|
|
18
|
+
from .compiler import parser as P
|
|
19
|
+
from .compiler.lower import is_ui_stmt
|
|
20
|
+
from .context import NotFound, Redirect
|
|
21
|
+
from .ssr import Renderer, page_html
|
|
22
|
+
|
|
23
|
+
STATE_PREFIX = "__pyweb_state_"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _state_function(fn):
|
|
27
|
+
"""Copy a page/component function into one that returns its locals.
|
|
28
|
+
|
|
29
|
+
Markup placeholders are dropped; an explicit ``return`` (e.g.
|
|
30
|
+
``return redirect("/login")``) still returns early.
|
|
31
|
+
"""
|
|
32
|
+
new = copy.deepcopy(fn)
|
|
33
|
+
new.name = STATE_PREFIX + fn.name
|
|
34
|
+
new.decorator_list = []
|
|
35
|
+
body = [s for s in new.body if not is_ui_stmt(s)]
|
|
36
|
+
ret = ast.Return(value=ast.Call(func=ast.Name(id="locals", ctx=ast.Load()), args=[], keywords=[]))
|
|
37
|
+
body.append(ret)
|
|
38
|
+
new.body = body
|
|
39
|
+
ast.fix_missing_locations(new)
|
|
40
|
+
return new
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LoadedApp:
|
|
44
|
+
def __init__(self, path=None, *, source=None, filename=None, asset_urls=None,
|
|
45
|
+
stylesheets=None):
|
|
46
|
+
if source is None:
|
|
47
|
+
with open(path, encoding="utf-8") as fh:
|
|
48
|
+
source = fh.read()
|
|
49
|
+
self.path = path
|
|
50
|
+
self.filename = filename or path or "app.pyweb"
|
|
51
|
+
self.source = source
|
|
52
|
+
self.asset_urls = dict(asset_urls or {})
|
|
53
|
+
self.extra_stylesheets = list(stylesheets or [])
|
|
54
|
+
self.compiled = compile_source(source, filename=self.filename)
|
|
55
|
+
self.ctx = self.compiled["context"]
|
|
56
|
+
self.module = self._exec()
|
|
57
|
+
self.rpc = {}
|
|
58
|
+
for spec in self.compiled["rpc"]:
|
|
59
|
+
fn = self.module.__dict__.get(spec["name"])
|
|
60
|
+
if callable(fn):
|
|
61
|
+
self.rpc[spec["name"]] = fn
|
|
62
|
+
app_obj = next((v for v in self.module.__dict__.values()
|
|
63
|
+
if type(v).__name__ == "App" and type(v).__module__.startswith("pyweb")), None)
|
|
64
|
+
self.app = app_obj
|
|
65
|
+
self.title = getattr(app_obj, "title", None) or "PyWeb"
|
|
66
|
+
self.stylesheets = list(getattr(app_obj, "stylesheets", []) or []) + self.extra_stylesheets
|
|
67
|
+
self.lang = getattr(app_obj, "lang", "en") or "en"
|
|
68
|
+
|
|
69
|
+
# ----------------------------------------------------------- module
|
|
70
|
+
def _exec(self):
|
|
71
|
+
py_source, _ui = P.split_sources(self.source)
|
|
72
|
+
tree = ast.parse(py_source, filename=self.filename)
|
|
73
|
+
infos = [p["info"] for p in self.compiled["pages"].values()]
|
|
74
|
+
infos += list(self.compiled["components"].values())
|
|
75
|
+
for info in infos:
|
|
76
|
+
tree.body.append(_state_function(info.node))
|
|
77
|
+
stem = os.path.splitext(os.path.basename(self.filename))[0] or "app"
|
|
78
|
+
mod = types.ModuleType(f"pyweb_app_{stem}")
|
|
79
|
+
mod.__file__ = os.path.abspath(self.filename) if self.path else self.filename
|
|
80
|
+
mod.__dict__["__pyweb_ui__"] = lambda *_a: None
|
|
81
|
+
app_dir = os.path.dirname(os.path.abspath(self.path)) if self.path else None
|
|
82
|
+
if app_dir and app_dir not in sys.path:
|
|
83
|
+
sys.path.insert(0, app_dir)
|
|
84
|
+
sys.modules[mod.__name__] = mod
|
|
85
|
+
exec(compile(tree, self.filename, "exec"), mod.__dict__) # noqa: S102 - the app itself
|
|
86
|
+
return mod
|
|
87
|
+
|
|
88
|
+
# -------------------------------------------------------- rendering
|
|
89
|
+
def _component_state(self, name, props):
|
|
90
|
+
info = self.compiled["components"][name]
|
|
91
|
+
fn = self.module.__dict__[STATE_PREFIX + name]
|
|
92
|
+
env = fn(**{k: v for k, v in props.items() if k in info.params})
|
|
93
|
+
return info.ui, env
|
|
94
|
+
|
|
95
|
+
def renderer(self):
|
|
96
|
+
return Renderer(self.module.__dict__, component_state=self._component_state, strict=True)
|
|
97
|
+
|
|
98
|
+
def coerce_params(self, page_name, raw):
|
|
99
|
+
fn = self.module.__dict__[STATE_PREFIX + page_name]
|
|
100
|
+
out = {}
|
|
101
|
+
for pname, param in inspect.signature(fn).parameters.items():
|
|
102
|
+
if pname not in raw:
|
|
103
|
+
continue
|
|
104
|
+
ann = param.annotation
|
|
105
|
+
value = raw[pname]
|
|
106
|
+
try:
|
|
107
|
+
if ann in (int, "int"):
|
|
108
|
+
value = int(value)
|
|
109
|
+
elif ann in (float, "float"):
|
|
110
|
+
value = float(value)
|
|
111
|
+
except ValueError:
|
|
112
|
+
raise NotFound(f"{pname}={value!r}") from None
|
|
113
|
+
out[pname] = value
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
def render(self, page_name, params=None):
|
|
117
|
+
"""Return ``html`` (str) or a :class:`~pyweb.context.Redirect`."""
|
|
118
|
+
page = self.compiled["pages"][page_name]
|
|
119
|
+
info = page["info"]
|
|
120
|
+
fn = self.module.__dict__[STATE_PREFIX + page_name]
|
|
121
|
+
args = self.coerce_params(page_name, params or {})
|
|
122
|
+
result = fn(**args)
|
|
123
|
+
if isinstance(result, Redirect):
|
|
124
|
+
return result
|
|
125
|
+
if not isinstance(result, dict):
|
|
126
|
+
raise TypeError(f"page {page_name}() returned {type(result).__name__}; pages may only "
|
|
127
|
+
"return redirect(...)")
|
|
128
|
+
env = dict(result)
|
|
129
|
+
body = self.renderer().render(info.ui, env)
|
|
130
|
+
state = {k: env.get(k) for k in info.sent}
|
|
131
|
+
js_url = self.asset_urls.get(page_name)
|
|
132
|
+
if js_url is None and page["js"]:
|
|
133
|
+
js_url = page["js_url"]
|
|
134
|
+
title = info.title or self.title
|
|
135
|
+
if callable(getattr(self.app, "title_for", None)):
|
|
136
|
+
title = self.app.title_for(page_name) or title
|
|
137
|
+
return page_html(name=page_name, title=title, body=body, state=state,
|
|
138
|
+
js_url=js_url if page["js"] else None, css_urls=self.stylesheets,
|
|
139
|
+
lang=self.lang)
|
pyweb/asgi.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""ASGI adapter: run a PyWeb app under uvicorn, hypercorn or gunicorn.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
# app_asgi.py
|
|
6
|
+
from pyweb.asgi import create_app
|
|
7
|
+
app = create_app("app.pyweb") # or a built "dist" directory
|
|
8
|
+
|
|
9
|
+
$ uvicorn app_asgi:app --workers 4
|
|
10
|
+
|
|
11
|
+
Page rendering and RPC calls are synchronous Python, so each request
|
|
12
|
+
runs in a worker thread (``asyncio.to_thread``) and never blocks the
|
|
13
|
+
event loop. Request bodies are capped at ``max_body`` bytes (413).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
from .hosting import Site
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app(target="app.pyweb", *, debug=False, max_body=1_048_576, **server_kwargs):
|
|
24
|
+
"""Return an ASGI 3 application serving ``target`` (a `.pyweb` file or dist dir)."""
|
|
25
|
+
site = Site(target, debug=debug, max_body=max_body, **server_kwargs)
|
|
26
|
+
|
|
27
|
+
async def app(scope, receive, send):
|
|
28
|
+
if scope["type"] == "lifespan":
|
|
29
|
+
while True:
|
|
30
|
+
msg = await receive()
|
|
31
|
+
if msg["type"] == "lifespan.startup":
|
|
32
|
+
await send({"type": "lifespan.startup.complete"})
|
|
33
|
+
elif msg["type"] == "lifespan.shutdown":
|
|
34
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
35
|
+
return
|
|
36
|
+
if scope["type"] != "http":
|
|
37
|
+
return
|
|
38
|
+
chunks, size, too_big = [], 0, False
|
|
39
|
+
while True:
|
|
40
|
+
msg = await receive()
|
|
41
|
+
if msg["type"] == "http.disconnect":
|
|
42
|
+
return
|
|
43
|
+
part = msg.get("body", b"")
|
|
44
|
+
size += len(part)
|
|
45
|
+
if size > max_body:
|
|
46
|
+
too_big = True
|
|
47
|
+
elif part:
|
|
48
|
+
chunks.append(part)
|
|
49
|
+
if not msg.get("more_body"):
|
|
50
|
+
break
|
|
51
|
+
headers = {}
|
|
52
|
+
for k, v in scope.get("headers", []):
|
|
53
|
+
name = k.decode("latin-1").title()
|
|
54
|
+
headers[name] = v.decode("latin-1")
|
|
55
|
+
path = scope.get("path", "/")
|
|
56
|
+
if scope.get("query_string"):
|
|
57
|
+
path += "?" + scope["query_string"].decode("latin-1")
|
|
58
|
+
if too_big:
|
|
59
|
+
status, hdrs, body = 413, [("Content-Type", "application/json")], \
|
|
60
|
+
b'{"error": {"code": "http_413", "message": "request body too large"}}'
|
|
61
|
+
else:
|
|
62
|
+
status, hdrs, body = await asyncio.to_thread(
|
|
63
|
+
site.respond, scope.get("method", "GET"), path, headers, b"".join(chunks))
|
|
64
|
+
raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in hdrs]
|
|
65
|
+
if not any(k == b"content-length" for k, _ in raw_headers):
|
|
66
|
+
raw_headers.append((b"content-length", str(len(body)).encode()))
|
|
67
|
+
await send({"type": "http.response.start", "status": status, "headers": raw_headers})
|
|
68
|
+
await send({"type": "http.response.body", "body": body})
|
|
69
|
+
|
|
70
|
+
app.site = site
|
|
71
|
+
return app
|