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/__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