pywire-templates 0.2.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.
- pywire_templates/__init__.py +29 -0
- pywire_templates/deploy/Dockerfile.j2 +14 -0
- pywire_templates/deploy/entry.py.j2 +35 -0
- pywire_templates/deploy/fly.toml.j2 +19 -0
- pywire_templates/deploy/pywire_do.py.j2 +180 -0
- pywire_templates/deploy/railway.json.j2 +6 -0
- pywire_templates/deploy/render.yaml.j2 +25 -0
- pywire_templates/deploy/wrangler.toml.j2 +18 -0
- pywire_templates-0.2.0.dist-info/METADATA +46 -0
- pywire_templates-0.2.0.dist-info/RECORD +11 -0
- pywire_templates-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared Jinja2 templates for PyWire deploy + scaffolding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jinja2
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _deploy_env() -> jinja2.Environment:
|
|
14
|
+
return jinja2.Environment(
|
|
15
|
+
loader=jinja2.PackageLoader("pywire_templates", "deploy"),
|
|
16
|
+
trim_blocks=True,
|
|
17
|
+
lstrip_blocks=True,
|
|
18
|
+
keep_trailing_newline=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_deploy_template(name: str, **context: Any) -> str:
|
|
23
|
+
"""Render a deploy template by file name (e.g. ``Dockerfile.j2``)."""
|
|
24
|
+
return _deploy_env().get_template(name).render(**context)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def deploy_template_path(name: str) -> str:
|
|
28
|
+
"""Return absolute path to a deploy template file — useful for copies."""
|
|
29
|
+
return str(resources.files("pywire_templates").joinpath("deploy", name))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
|
|
4
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
|
5
|
+
COPY pyproject.toml uv.lock ./
|
|
6
|
+
RUN uv sync --frozen --no-dev
|
|
7
|
+
|
|
8
|
+
COPY . .
|
|
9
|
+
|
|
10
|
+
# Session timeout in seconds (default: 1800 = 30 min)
|
|
11
|
+
ENV SESSION_TTL=1800
|
|
12
|
+
|
|
13
|
+
EXPOSE 8000
|
|
14
|
+
CMD ["uv", "run", "pywire", "run", "--host", "0.0.0.0", "--port", "8000", "--workers", "{{ workers }}"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asgi
|
|
2
|
+
from {{ app_module }} import {{ app_attr }}
|
|
3
|
+
import _routes # noqa: F401 — registers routes as a side effect
|
|
4
|
+
from pywire_do import PyWireSessionDO
|
|
5
|
+
from workers import WorkerEntrypoint, Response
|
|
6
|
+
from js import URL, Request as JsRequest
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Default(WorkerEntrypoint):
|
|
11
|
+
async def on_fetch(self, request):
|
|
12
|
+
url = URL.new(request.url)
|
|
13
|
+
upgrade = (request.headers.get("Upgrade") or "").lower()
|
|
14
|
+
|
|
15
|
+
if upgrade == "websocket":
|
|
16
|
+
session_id = url.searchParams.get("session") or str(uuid.uuid4())
|
|
17
|
+
stub = self.env.PYWIRE_SESSION.getByName(session_id)
|
|
18
|
+
return await stub.fetch(request)
|
|
19
|
+
|
|
20
|
+
# Pre-warm a Durable Object so it's ready when the client opens a WS.
|
|
21
|
+
# The DO cold start overlaps with the browser loading HTML + JS.
|
|
22
|
+
session_id = str(uuid.uuid4())
|
|
23
|
+
stub = self.env.PYWIRE_SESSION.getByName(session_id)
|
|
24
|
+
self.ctx.waitUntil(stub.fetch(JsRequest.new("http://do/warm")))
|
|
25
|
+
|
|
26
|
+
# Serve the page via ASGI
|
|
27
|
+
response = await asgi.fetch({{ app_attr }}, request.js_object, self.env)
|
|
28
|
+
|
|
29
|
+
# Inject pre-assigned session into the HTML so the client
|
|
30
|
+
# connects to the already-warming DO instead of creating a new one.
|
|
31
|
+
body = await response.text()
|
|
32
|
+
meta = '<meta name="pywire-session" content="' + session_id + '">'
|
|
33
|
+
body = body.replace("</head>", meta + "</head>", 1)
|
|
34
|
+
|
|
35
|
+
return Response.new(body, status=response.status, headers=response.headers)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
app = "{{ project_name }}"
|
|
2
|
+
primary_region = "ord"
|
|
3
|
+
|
|
4
|
+
[build]
|
|
5
|
+
dockerfile = "Dockerfile"
|
|
6
|
+
|
|
7
|
+
[http_service]
|
|
8
|
+
internal_port = 8000
|
|
9
|
+
force_https = true
|
|
10
|
+
auto_stop_machines = "stop"
|
|
11
|
+
auto_start_machines = true
|
|
12
|
+
|
|
13
|
+
[env]
|
|
14
|
+
# Session timeout in seconds (default: 1800 = 30 min)
|
|
15
|
+
SESSION_TTL = "1800"
|
|
16
|
+
|
|
17
|
+
[[vm]]
|
|
18
|
+
memory = "512mb"
|
|
19
|
+
cpus = 1
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""PyWire Durable Object — session actor with WebSocket hibernation.
|
|
2
|
+
|
|
3
|
+
This module is generated by `pywire build --platform cloudflare`.
|
|
4
|
+
Uses shared pywire.runtime modules for protocol + page resolution.
|
|
5
|
+
|
|
6
|
+
All imports are at the top level so they are captured in the Cloudflare
|
|
7
|
+
Workers memory snapshot at deploy time, giving fast cold starts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from workers import DurableObject, Response
|
|
11
|
+
from js import WebSocketPair
|
|
12
|
+
from pyodide.ffi import to_js
|
|
13
|
+
from {{ app_module }} import {{ app_attr }}
|
|
14
|
+
import _routes # noqa: F401 — registers routes as a side effect
|
|
15
|
+
import msgpack
|
|
16
|
+
from pywire.runtime.session_serializer import snapshot_page_state, restore_page_state
|
|
17
|
+
from pywire.runtime.protocol import build_update_payload
|
|
18
|
+
from pywire.runtime.page_resolver import resolve_page
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _send(ws, msg):
|
|
22
|
+
ws.send(to_js(msgpack.packb(msg)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _send_update(ws, update):
|
|
26
|
+
_send(ws, build_update_payload(update))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _decode(message):
|
|
30
|
+
try:
|
|
31
|
+
if isinstance(message, (bytes, bytearray, memoryview)):
|
|
32
|
+
return msgpack.unpackb(message, raw=False)
|
|
33
|
+
if hasattr(message, "to_py"):
|
|
34
|
+
return msgpack.unpackb(bytes(message.to_py()), raw=False)
|
|
35
|
+
return {}
|
|
36
|
+
except Exception:
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_page(path):
|
|
41
|
+
result = resolve_page({{ app_attr }}.router, path)
|
|
42
|
+
if result is None:
|
|
43
|
+
return None
|
|
44
|
+
page, _params, _variant_name = result
|
|
45
|
+
return page
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PyWireSessionDO(DurableObject):
|
|
49
|
+
def __init__(self, state, env):
|
|
50
|
+
super().__init__(state, env)
|
|
51
|
+
self.ctx = state
|
|
52
|
+
self.page = None
|
|
53
|
+
|
|
54
|
+
async def on_fetch(self, request):
|
|
55
|
+
upgrade = (request.headers.get("Upgrade") or "").lower()
|
|
56
|
+
if upgrade == "websocket":
|
|
57
|
+
pair = WebSocketPair.new()
|
|
58
|
+
client, server = pair.object_values()
|
|
59
|
+
self.ctx.acceptWebSocket(server)
|
|
60
|
+
return Response(None, status=101, web_socket=client)
|
|
61
|
+
return Response("OK", headers={"Content-Type": "text/plain"})
|
|
62
|
+
|
|
63
|
+
async def on_webSocketMessage(self, ws, message):
|
|
64
|
+
msg = _decode(message)
|
|
65
|
+
msg_type = msg.get("type", "")
|
|
66
|
+
try:
|
|
67
|
+
if msg_type == "init":
|
|
68
|
+
await self._handle_init(ws, msg)
|
|
69
|
+
elif msg_type == "event":
|
|
70
|
+
await self._handle_event(ws, msg)
|
|
71
|
+
elif msg_type == "relocate":
|
|
72
|
+
await self._handle_relocate(ws, msg)
|
|
73
|
+
elif msg_type == "pong":
|
|
74
|
+
pass
|
|
75
|
+
except Exception as e:
|
|
76
|
+
import traceback
|
|
77
|
+
traceback.print_exc()
|
|
78
|
+
_send(ws, {"type": "error", "error": str(e)})
|
|
79
|
+
|
|
80
|
+
async def on_webSocketClose(self, ws, code, reason, was_clean):
|
|
81
|
+
if self.page:
|
|
82
|
+
await self._persist_state()
|
|
83
|
+
ws.close(code, reason)
|
|
84
|
+
|
|
85
|
+
async def _restore_from_storage(self, page):
|
|
86
|
+
snapshot = await self.ctx.storage.get("page_state")
|
|
87
|
+
if snapshot:
|
|
88
|
+
try:
|
|
89
|
+
state = snapshot.to_py() if hasattr(snapshot, "to_py") else snapshot
|
|
90
|
+
restore_page_state(page, state)
|
|
91
|
+
return True
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def _setup_broadcaster(self, ws, page):
|
|
97
|
+
async def broadcast_update():
|
|
98
|
+
update = await page.render_update(init=False)
|
|
99
|
+
_send_update(ws, update)
|
|
100
|
+
page._on_update = broadcast_update
|
|
101
|
+
|
|
102
|
+
async def _handle_init(self, ws, msg):
|
|
103
|
+
path = msg.get("path", "/")
|
|
104
|
+
self.page = _resolve_page(path)
|
|
105
|
+
if not self.page:
|
|
106
|
+
_send(ws, {"type": "error", "error": "Not Found"})
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
session_restored = await self._restore_from_storage(self.page)
|
|
110
|
+
self._setup_broadcaster(ws, self.page)
|
|
111
|
+
await self.page.render(init=True)
|
|
112
|
+
|
|
113
|
+
if self.page._pending_navigation:
|
|
114
|
+
_send(ws, {"type": "navigate", "path": self.page._pending_navigation})
|
|
115
|
+
self.page._pending_navigation = None
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
_send(ws, {
|
|
119
|
+
"type": "init_ack",
|
|
120
|
+
"session_id": "",
|
|
121
|
+
"session_restored": session_restored,
|
|
122
|
+
})
|
|
123
|
+
await self.page._run_hooks(self.page.MOUNT_HOOKS)
|
|
124
|
+
await self._persist_state()
|
|
125
|
+
|
|
126
|
+
async def _handle_event(self, ws, msg):
|
|
127
|
+
handler_name = msg.get("handler")
|
|
128
|
+
event_data = msg.get("data", {})
|
|
129
|
+
|
|
130
|
+
if not self.page:
|
|
131
|
+
self.page = _resolve_page(msg.get("path", "/"))
|
|
132
|
+
if not self.page:
|
|
133
|
+
_send(ws, {"type": "error", "error": "No page"})
|
|
134
|
+
return
|
|
135
|
+
await self._restore_from_storage(self.page)
|
|
136
|
+
await self.page.render(init=True)
|
|
137
|
+
|
|
138
|
+
self._setup_broadcaster(ws, self.page)
|
|
139
|
+
|
|
140
|
+
if handler_name:
|
|
141
|
+
update = await self.page.handle_event(handler_name, event_data)
|
|
142
|
+
else:
|
|
143
|
+
update = await self.page.render_update(init=False)
|
|
144
|
+
|
|
145
|
+
if self.page._pending_navigation:
|
|
146
|
+
_send(ws, {"type": "navigate", "path": self.page._pending_navigation})
|
|
147
|
+
self.page._pending_navigation = None
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
_send_update(ws, update)
|
|
151
|
+
await self.page._run_hooks(self.page.AFTER_UPDATE_HOOKS)
|
|
152
|
+
await self._persist_state()
|
|
153
|
+
|
|
154
|
+
async def _handle_relocate(self, ws, msg):
|
|
155
|
+
path = msg.get("path", "/")
|
|
156
|
+
old_page = self.page
|
|
157
|
+
|
|
158
|
+
self.page = _resolve_page(path)
|
|
159
|
+
if not self.page:
|
|
160
|
+
_send(ws, {"type": "reload"})
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if old_page:
|
|
164
|
+
self.page.user = getattr(old_page, "user", None)
|
|
165
|
+
|
|
166
|
+
self._setup_broadcaster(ws, self.page)
|
|
167
|
+
response = await self.page.render(init=False)
|
|
168
|
+
html = response.body.decode("utf-8") if isinstance(response.body, bytes) else response.body
|
|
169
|
+
_send(ws, {"type": "update", "html": html})
|
|
170
|
+
await self.page._run_hooks(self.page.MOUNT_HOOKS)
|
|
171
|
+
await self._persist_state()
|
|
172
|
+
|
|
173
|
+
async def _persist_state(self):
|
|
174
|
+
if not self.page:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
snapshot = snapshot_page_state(self.page)
|
|
178
|
+
await self.ctx.storage.put("page_state", to_js(snapshot))
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
services:
|
|
2
|
+
- type: web
|
|
3
|
+
name: {{ project_name }}
|
|
4
|
+
runtime: docker
|
|
5
|
+
{% if redis_enabled %}
|
|
6
|
+
plan: starter
|
|
7
|
+
envVars:
|
|
8
|
+
- key: REDIS_URL
|
|
9
|
+
fromService:
|
|
10
|
+
name: {{ project_name }}-kv
|
|
11
|
+
type: keyvalue
|
|
12
|
+
property: connectionString
|
|
13
|
+
- key: SESSION_TTL
|
|
14
|
+
value: "1800" # Session timeout in seconds (default: 30 min)
|
|
15
|
+
|
|
16
|
+
- type: keyvalue
|
|
17
|
+
name: {{ project_name }}-kv
|
|
18
|
+
plan: starter
|
|
19
|
+
ipAllowList: []
|
|
20
|
+
{% else %}
|
|
21
|
+
plan: free
|
|
22
|
+
envVars:
|
|
23
|
+
- key: SESSION_TTL
|
|
24
|
+
value: "1800" # Session timeout in seconds (default: 30 min)
|
|
25
|
+
{% endif %}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name = "{{ project_name }}"
|
|
2
|
+
main = "entry.py"
|
|
3
|
+
compatibility_date = "2025-01-01"
|
|
4
|
+
compatibility_flags = ["python_workers"]
|
|
5
|
+
workers_dev = false
|
|
6
|
+
preview_urls = false
|
|
7
|
+
|
|
8
|
+
[assets]
|
|
9
|
+
directory = "./.pywire/deploy/public"
|
|
10
|
+
binding = "ASSETS"
|
|
11
|
+
|
|
12
|
+
[[durable_objects.bindings]]
|
|
13
|
+
name = "PYWIRE_SESSION"
|
|
14
|
+
class_name = "PyWireSessionDO"
|
|
15
|
+
|
|
16
|
+
[[migrations]]
|
|
17
|
+
tag = "v1"
|
|
18
|
+
new_sqlite_classes = ["PyWireSessionDO"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pywire-templates
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared Jinja2 templates for PyWire deployment and scaffolding
|
|
5
|
+
Project-URL: Homepage, https://pywire.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/pywire/pywire
|
|
7
|
+
Author-email: Reece Holmdahl <reece@pywire.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: jinja2>=3.1.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pywire-templates
|
|
21
|
+
|
|
22
|
+
Shared Jinja2 templates consumed by `pywire-cli` (for `pywire deploy`) and `create-pywire-app` (for project scaffolding).
|
|
23
|
+
|
|
24
|
+
End users should not depend on this package directly. It exists so deployment configs (Dockerfile, fly.toml, render.yaml, wrangler.toml, railway.json, Cloudflare entry/DO) live in one place.
|
|
25
|
+
|
|
26
|
+
## Layout
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
src/pywire_templates/
|
|
30
|
+
deploy/ # deployment config templates
|
|
31
|
+
Dockerfile.j2
|
|
32
|
+
fly.toml.j2
|
|
33
|
+
render.yaml.j2
|
|
34
|
+
wrangler.toml.j2
|
|
35
|
+
railway.json.j2
|
|
36
|
+
entry.py.j2
|
|
37
|
+
pywire_do.py.j2
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pywire_templates import render_deploy_template
|
|
44
|
+
|
|
45
|
+
content = render_deploy_template("Dockerfile.j2", workers=4)
|
|
46
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pywire_templates/__init__.py,sha256=ceclc3g9hgCRfwyh7smPC7sk3TtAWIvrMaNqMFumLng,833
|
|
2
|
+
pywire_templates/deploy/Dockerfile.j2,sha256=KUVcV0_p9Dxdw6CaFKQmE5CsZXlhgixDRNvxQalwOlE,352
|
|
3
|
+
pywire_templates/deploy/entry.py.j2,sha256=1UlTdjheYJ6PKn1tDQJaFk4StYW6hsgMGKUPE0hxKRU,1499
|
|
4
|
+
pywire_templates/deploy/fly.toml.j2,sha256=k8rh159RpI1bc_1mCNcro4FLUyRtrEh2KwyNVkSumyk,330
|
|
5
|
+
pywire_templates/deploy/pywire_do.py.j2,sha256=KcrOMRok_-XA9IZHOFMr8T2DJyiCdfrObRL-Q2WENpc,6108
|
|
6
|
+
pywire_templates/deploy/railway.json.j2,sha256=l5eYYAkAvk7VmT-DNVybj8J82ws1Tw_zb6ESaTI9G-U,112
|
|
7
|
+
pywire_templates/deploy/render.yaml.j2,sha256=mIqoc2j0TJPCAEdUgs2oxuih_8C0s9usss-VdO46svk,602
|
|
8
|
+
pywire_templates/deploy/wrangler.toml.j2,sha256=hNv6H89fi1tVxiwILCoLC3PusWtLvqzxmXvITm9wuTs,382
|
|
9
|
+
pywire_templates-0.2.0.dist-info/METADATA,sha256=2UDhoncqcis5VyLmt_jbMFsY-Z1ZqnrnjsS98suyCvM,1441
|
|
10
|
+
pywire_templates-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
pywire_templates-0.2.0.dist-info/RECORD,,
|