pywire 0.1.1__py3-none-any.whl → 0.1.2__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/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +901 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +434 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
- pywire-0.1.2.dist-info/RECORD +104 -0
- pywire-0.1.1.dist-info/RECORD +0 -9
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -0
pywire/runtime/page.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Base page class with lifecycle system."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Any,
|
|
8
|
+
Awaitable,
|
|
9
|
+
Callable,
|
|
10
|
+
ClassVar,
|
|
11
|
+
Dict,
|
|
12
|
+
List,
|
|
13
|
+
Optional,
|
|
14
|
+
Set,
|
|
15
|
+
Tuple,
|
|
16
|
+
Union,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
from starlette.responses import Response
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pywire.runtime.router import URLHelper
|
|
24
|
+
|
|
25
|
+
from pywire.runtime.style_collector import StyleCollector
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EventData(dict):
|
|
29
|
+
"""Dict that allows dot-access to keys for Alpine.js compatibility."""
|
|
30
|
+
|
|
31
|
+
def __getattr__(self, name: str) -> Any:
|
|
32
|
+
try:
|
|
33
|
+
return self[name]
|
|
34
|
+
except KeyError:
|
|
35
|
+
# Check for camelCase version of name
|
|
36
|
+
import re
|
|
37
|
+
|
|
38
|
+
camel = re.sub(r"(?!^)_([a-z])", lambda x: x.group(1).upper(), name)
|
|
39
|
+
if camel in self:
|
|
40
|
+
return self[camel]
|
|
41
|
+
raise AttributeError(f"'EventData' object has no attribute '{name}'")
|
|
42
|
+
|
|
43
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
44
|
+
self[name] = value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BasePage:
|
|
48
|
+
"""Base class for all compiled pages."""
|
|
49
|
+
|
|
50
|
+
# Layout ID (overridden by generator)
|
|
51
|
+
LAYOUT_ID: Optional[str] = None
|
|
52
|
+
__file_path__: ClassVar[str]
|
|
53
|
+
|
|
54
|
+
# Lifecycle hooks registry (extensible!)
|
|
55
|
+
INIT_HOOKS = [
|
|
56
|
+
"on_before_load",
|
|
57
|
+
"on_load",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
RENDER_HOOKS = [
|
|
61
|
+
"on_after_render",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# Legacy support / full list
|
|
65
|
+
LIFECYCLE_HOOKS = INIT_HOOKS + RENDER_HOOKS
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
request: Request,
|
|
70
|
+
params: Dict[str, str],
|
|
71
|
+
query: Dict[str, str],
|
|
72
|
+
path: Optional[Dict[str, bool]] = None,
|
|
73
|
+
url: Optional["URLHelper"] = None,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.request = request
|
|
77
|
+
self.params = params or {} # URL params from route
|
|
78
|
+
self.query = query or {} # Query string params
|
|
79
|
+
self.path = path or {}
|
|
80
|
+
self.url = url
|
|
81
|
+
|
|
82
|
+
# Style collector management
|
|
83
|
+
# If passed from parent component (via kwargs), reuse it.
|
|
84
|
+
# Otherwise create new one (root page).
|
|
85
|
+
if "_style_collector" in kwargs:
|
|
86
|
+
self._style_collector: StyleCollector = kwargs.pop("_style_collector")
|
|
87
|
+
else:
|
|
88
|
+
self._style_collector = StyleCollector()
|
|
89
|
+
|
|
90
|
+
# Context inheritance for !provide/!inject
|
|
91
|
+
# If passed from parent component, make a shallow copy for child-specific shadowing.
|
|
92
|
+
# Otherwise create a new empty context (root page).
|
|
93
|
+
if "_context" in kwargs:
|
|
94
|
+
self.context = kwargs.pop("_context").copy()
|
|
95
|
+
else:
|
|
96
|
+
self.context = {}
|
|
97
|
+
self.context: Dict[str, Any] # type: ignore
|
|
98
|
+
|
|
99
|
+
self.user: Any = None # Set by middleware
|
|
100
|
+
|
|
101
|
+
# Expose params as attributes for easy access in templates
|
|
102
|
+
for k, v in self.params.items():
|
|
103
|
+
setattr(self, k, v)
|
|
104
|
+
|
|
105
|
+
# Framework-managed state
|
|
106
|
+
self.errors: Dict[str, str] = {}
|
|
107
|
+
self.loading: Dict[str, bool] = {}
|
|
108
|
+
|
|
109
|
+
# Slot registry: layout_id -> slot_name -> renderer (replacement semantics)
|
|
110
|
+
self.slots: Dict[str, Dict[str, Union[Callable, str]]] = defaultdict(dict)
|
|
111
|
+
|
|
112
|
+
# Populate slots from kwargs (for components)
|
|
113
|
+
if "slots" in kwargs and self.LAYOUT_ID:
|
|
114
|
+
self.slots[self.LAYOUT_ID].update(kwargs["slots"])
|
|
115
|
+
|
|
116
|
+
# Component flag (internal)
|
|
117
|
+
self.__is_component__ = kwargs.pop("__is_component__", False)
|
|
118
|
+
|
|
119
|
+
# Store remaining kwargs as fallthrough attributes
|
|
120
|
+
self.attrs = {k: v for k, v in kwargs.items() if k != "slots"}
|
|
121
|
+
|
|
122
|
+
# Head slot registry: layout_id -> list of renderers (append semantics, top-down order)
|
|
123
|
+
self.head_slots: Dict[str, List[Callable]] = defaultdict(list)
|
|
124
|
+
|
|
125
|
+
# Async update hook for intermediate state (injected by runtime)
|
|
126
|
+
self._on_update: Optional[Callable[[], Awaitable[None]]] = None
|
|
127
|
+
self._wire_subscribers: Dict[Tuple[Any, str], Set[str]] = defaultdict(set)
|
|
128
|
+
self._region_dependencies: Dict[str, Set[Tuple[Any, str]]] = defaultdict(set)
|
|
129
|
+
self._dirty_regions: Set[str] = set()
|
|
130
|
+
|
|
131
|
+
# Error state for error pages
|
|
132
|
+
self.error_code: Optional[int] = None
|
|
133
|
+
self.error_detail: Optional[str] = None
|
|
134
|
+
self.error_trace: Optional[str] = None
|
|
135
|
+
|
|
136
|
+
def register_slot(
|
|
137
|
+
self, layout_id: str, slot_name: str, renderer: Callable[..., Any]
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Register a content renderer for a slot in a specific layout."""
|
|
140
|
+
self.slots[layout_id][slot_name] = renderer
|
|
141
|
+
|
|
142
|
+
def register_head_slot(self, layout_id: str, renderer: Callable[..., Any]) -> None:
|
|
143
|
+
"""Register head content to be appended (top-down order)."""
|
|
144
|
+
# Prevent duplicate registration (can happen with super()._init_slots() chaining)
|
|
145
|
+
if renderer not in self.head_slots[layout_id]:
|
|
146
|
+
self.head_slots[layout_id].append(renderer)
|
|
147
|
+
|
|
148
|
+
async def render_slot(
|
|
149
|
+
self,
|
|
150
|
+
slot_name: str,
|
|
151
|
+
default_renderer: Optional[Callable[..., Any]] = None,
|
|
152
|
+
layout_id: Optional[str] = None,
|
|
153
|
+
append: bool = False,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Render a slot for the current layout."""
|
|
156
|
+
target_id = layout_id or self.LAYOUT_ID
|
|
157
|
+
|
|
158
|
+
# Handle $head slots with append semantics
|
|
159
|
+
if append:
|
|
160
|
+
parts = []
|
|
161
|
+
# Render default content first (from the layout itself)
|
|
162
|
+
if default_renderer:
|
|
163
|
+
if inspect.iscoroutinefunction(default_renderer):
|
|
164
|
+
parts.append(await default_renderer())
|
|
165
|
+
else:
|
|
166
|
+
parts.append(default_renderer())
|
|
167
|
+
|
|
168
|
+
# Collect head content from ALL layout IDs in the inheritance chain
|
|
169
|
+
for layout_id_key in self.head_slots:
|
|
170
|
+
for head_renderer in self.head_slots[layout_id_key]:
|
|
171
|
+
if inspect.iscoroutinefunction(head_renderer):
|
|
172
|
+
parts.append(await head_renderer())
|
|
173
|
+
else:
|
|
174
|
+
parts.append(head_renderer())
|
|
175
|
+
return "".join(parts)
|
|
176
|
+
|
|
177
|
+
# Normal replacement semantics
|
|
178
|
+
if target_id and slot_name in self.slots[target_id]:
|
|
179
|
+
renderer: Union[Callable[..., Any], str] = self.slots[target_id][slot_name]
|
|
180
|
+
if callable(renderer):
|
|
181
|
+
if inspect.iscoroutinefunction(renderer):
|
|
182
|
+
return str(await renderer())
|
|
183
|
+
return str(renderer())
|
|
184
|
+
return str(renderer)
|
|
185
|
+
|
|
186
|
+
# Fallback to default content if provided
|
|
187
|
+
if default_renderer:
|
|
188
|
+
if inspect.iscoroutinefunction(default_renderer):
|
|
189
|
+
return str(await default_renderer())
|
|
190
|
+
return str(default_renderer())
|
|
191
|
+
|
|
192
|
+
return ""
|
|
193
|
+
|
|
194
|
+
async def render(self, init: bool = True) -> Response:
|
|
195
|
+
"""Main render method - calls lifecycle hooks."""
|
|
196
|
+
# Run init hooks only if requested (new page load)
|
|
197
|
+
if init:
|
|
198
|
+
for hook_name in self.INIT_HOOKS:
|
|
199
|
+
if hasattr(self, hook_name):
|
|
200
|
+
hook = getattr(self, hook_name)
|
|
201
|
+
if inspect.iscoroutinefunction(hook):
|
|
202
|
+
await hook()
|
|
203
|
+
else:
|
|
204
|
+
hook()
|
|
205
|
+
|
|
206
|
+
# Render template (may be async for layouts with render_slot calls)
|
|
207
|
+
# Render HTML
|
|
208
|
+
self._clear_wire_tracking()
|
|
209
|
+
html = await self._render_template()
|
|
210
|
+
|
|
211
|
+
# Inject styles if this is the root render (not a component or partial update)
|
|
212
|
+
# Actually, BasePage.render() is called for the ROOT page response.
|
|
213
|
+
# Components use _render_template directly.
|
|
214
|
+
# So here we can inject the styles into <head>.
|
|
215
|
+
|
|
216
|
+
styles = self._style_collector.render()
|
|
217
|
+
if styles:
|
|
218
|
+
# Inject into head
|
|
219
|
+
if "</head>" in html:
|
|
220
|
+
html = html.replace("</head>", f"{styles}</head>", 1)
|
|
221
|
+
else:
|
|
222
|
+
# Fallback: prepend to body or just prepend
|
|
223
|
+
html = f"{styles}{html}"
|
|
224
|
+
|
|
225
|
+
# Run post-render hooks (always run on render)
|
|
226
|
+
for hook_name in self.RENDER_HOOKS:
|
|
227
|
+
if hasattr(self, hook_name):
|
|
228
|
+
hook = getattr(self, hook_name)
|
|
229
|
+
if inspect.iscoroutinefunction(hook):
|
|
230
|
+
await hook()
|
|
231
|
+
else:
|
|
232
|
+
hook()
|
|
233
|
+
|
|
234
|
+
return Response(html, media_type="text/html")
|
|
235
|
+
|
|
236
|
+
def _clear_wire_tracking(self) -> None:
|
|
237
|
+
self._wire_subscribers.clear()
|
|
238
|
+
self._region_dependencies.clear()
|
|
239
|
+
self._dirty_regions.clear()
|
|
240
|
+
|
|
241
|
+
def _begin_region_render(self, region_id: str) -> None:
|
|
242
|
+
deps = self._region_dependencies.get(region_id)
|
|
243
|
+
if deps:
|
|
244
|
+
for dep in deps:
|
|
245
|
+
regions = self._wire_subscribers.get(dep)
|
|
246
|
+
if regions and region_id in regions:
|
|
247
|
+
regions.discard(region_id)
|
|
248
|
+
if not regions:
|
|
249
|
+
self._wire_subscribers.pop(dep, None)
|
|
250
|
+
self._region_dependencies[region_id] = set()
|
|
251
|
+
|
|
252
|
+
def _register_wire_read(self, wire_obj: Any, field: str, region_id: str) -> None:
|
|
253
|
+
key = (wire_obj, field)
|
|
254
|
+
self._wire_subscribers[key].add(region_id)
|
|
255
|
+
self._region_dependencies[region_id].add(key)
|
|
256
|
+
|
|
257
|
+
def _invalidate_wire(self, wire_obj: Any, field: str) -> None:
|
|
258
|
+
regions = set()
|
|
259
|
+
key = (wire_obj, field)
|
|
260
|
+
if key in self._wire_subscribers:
|
|
261
|
+
regions |= self._wire_subscribers[key]
|
|
262
|
+
if regions:
|
|
263
|
+
self._dirty_regions.update(regions)
|
|
264
|
+
|
|
265
|
+
async def handle_event(
|
|
266
|
+
self, event_name: str, event_data: dict[str, Any]
|
|
267
|
+
) -> Dict[str, Any]:
|
|
268
|
+
"""Handle client event (from @click, etc.)."""
|
|
269
|
+
|
|
270
|
+
# Security: Validate handler is allowed (prevents arbitrary method invocation)
|
|
271
|
+
allowed = getattr(self, "__allowed_handlers__", None)
|
|
272
|
+
|
|
273
|
+
# Framework-generated handlers are always allowed (form wrappers, bindings)
|
|
274
|
+
is_framework_handler = (
|
|
275
|
+
event_name.startswith("_handle_bind_")
|
|
276
|
+
or event_name.startswith("_handler_")
|
|
277
|
+
or event_name.startswith("_form_submit_")
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if not is_framework_handler:
|
|
281
|
+
# Block private methods (leading underscore) unless in allowlist
|
|
282
|
+
if event_name.startswith("_"):
|
|
283
|
+
raise ValueError(f"Handler '{event_name}' not allowed")
|
|
284
|
+
|
|
285
|
+
# Check explicit allowlist if defined
|
|
286
|
+
if allowed is not None and event_name not in allowed:
|
|
287
|
+
raise ValueError(f"Handler '{event_name}' not allowed")
|
|
288
|
+
|
|
289
|
+
# Retrieve handler
|
|
290
|
+
handler = getattr(self, event_name, None)
|
|
291
|
+
if not handler:
|
|
292
|
+
raise ValueError(f"Handler {event_name} not found")
|
|
293
|
+
|
|
294
|
+
# Call handler
|
|
295
|
+
if event_name.startswith("_handle_bind_"):
|
|
296
|
+
# Binding handlers expect raw event_data
|
|
297
|
+
if inspect.iscoroutinefunction(handler):
|
|
298
|
+
await handler(event_data)
|
|
299
|
+
else:
|
|
300
|
+
handler(event_data)
|
|
301
|
+
else:
|
|
302
|
+
# Regular handlers: intelligent argument mapping
|
|
303
|
+
args = event_data.get("args", {})
|
|
304
|
+
|
|
305
|
+
# Normalize args keys (arg-0 -> arg0) because dataset keys preserve hyphens
|
|
306
|
+
# before digits
|
|
307
|
+
normalized_args = {}
|
|
308
|
+
for k, v in args.items():
|
|
309
|
+
if k.startswith("arg"):
|
|
310
|
+
normalized_args[k.replace("-", "")] = v
|
|
311
|
+
else:
|
|
312
|
+
normalized_args[k] = v
|
|
313
|
+
|
|
314
|
+
call_kwargs = {k: v for k, v in event_data.items() if k != "args"}
|
|
315
|
+
call_kwargs.update(normalized_args)
|
|
316
|
+
|
|
317
|
+
# Check signature to see what arguments the handler accepts
|
|
318
|
+
sig = inspect.signature(handler)
|
|
319
|
+
bound_kwargs = {}
|
|
320
|
+
|
|
321
|
+
has_var_kw = False
|
|
322
|
+
for param in sig.parameters.values():
|
|
323
|
+
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
324
|
+
has_var_kw = True
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
if has_var_kw:
|
|
328
|
+
# If accepts **kwargs, pass everything
|
|
329
|
+
bound_kwargs = call_kwargs
|
|
330
|
+
else:
|
|
331
|
+
# Only pass arguments that match parameters
|
|
332
|
+
for name in sig.parameters:
|
|
333
|
+
if name == "event_data" or name == "event":
|
|
334
|
+
bound_kwargs[name] = EventData(call_kwargs)
|
|
335
|
+
elif name in call_kwargs:
|
|
336
|
+
bound_kwargs[name] = call_kwargs[name]
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
if inspect.iscoroutinefunction(handler):
|
|
340
|
+
await handler(**bound_kwargs)
|
|
341
|
+
else:
|
|
342
|
+
handler(**bound_kwargs)
|
|
343
|
+
except Exception as e:
|
|
344
|
+
# Let the runtime handle logging and reporting
|
|
345
|
+
raise e
|
|
346
|
+
|
|
347
|
+
# Re-render without re-initializing
|
|
348
|
+
return await self.render_update(init=False)
|
|
349
|
+
|
|
350
|
+
async def render_update(self, init: bool = False) -> Dict[str, Any]:
|
|
351
|
+
if hasattr(self, "__region_renderers__") and self._dirty_regions:
|
|
352
|
+
updates = []
|
|
353
|
+
region_map = getattr(self, "__region_renderers__", {}) or {}
|
|
354
|
+
for region_id in sorted(self._dirty_regions):
|
|
355
|
+
method_name = region_map.get(region_id)
|
|
356
|
+
if not method_name:
|
|
357
|
+
continue
|
|
358
|
+
renderer = getattr(self, method_name, None)
|
|
359
|
+
if not renderer:
|
|
360
|
+
continue
|
|
361
|
+
if inspect.iscoroutinefunction(renderer):
|
|
362
|
+
region_html = await renderer()
|
|
363
|
+
else:
|
|
364
|
+
region_html = renderer()
|
|
365
|
+
updates.append({"region": region_id, "html": region_html})
|
|
366
|
+
self._dirty_regions.clear()
|
|
367
|
+
if updates:
|
|
368
|
+
return {"type": "regions", "regions": updates}
|
|
369
|
+
|
|
370
|
+
response = await self.render(init=init)
|
|
371
|
+
html = bytes(response.body).decode("utf-8")
|
|
372
|
+
return {"type": "full", "html": html}
|
|
373
|
+
|
|
374
|
+
async def push_state(self) -> None:
|
|
375
|
+
"""Force a UI update with current state (useful for streaming progress)."""
|
|
376
|
+
if self._on_update:
|
|
377
|
+
if inspect.iscoroutinefunction(self._on_update):
|
|
378
|
+
await self._on_update()
|
|
379
|
+
else:
|
|
380
|
+
self._on_update()
|
|
381
|
+
|
|
382
|
+
async def _render_template(self) -> str:
|
|
383
|
+
"""Render template - implemented by codegen."""
|
|
384
|
+
return ""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional, Tuple, Type
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ValidationError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_with_model(
|
|
7
|
+
data: Dict[str, Any], model_class: Type[BaseModel]
|
|
8
|
+
) -> Tuple[Optional[BaseModel], Dict[str, str]]:
|
|
9
|
+
"""
|
|
10
|
+
Attempt to instantiate and validate a Pydantic model.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
data: The input dictionary (already type-converted by FormValidator if possible,
|
|
14
|
+
but Pydantic handles its own conversion too).
|
|
15
|
+
model_class: The Pydantic model class to validate against.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
(model_instance, {}) on success.
|
|
19
|
+
(None, {field_name: error_message}) on validation failure.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
# Pydantic v2 use model_validate, v1 use parse_obj.
|
|
23
|
+
# Let's support v2 primarily, but fallback if needed.
|
|
24
|
+
if hasattr(model_class, "model_validate"):
|
|
25
|
+
instance = model_class.model_validate(data)
|
|
26
|
+
else:
|
|
27
|
+
instance = model_class.parse_obj(data)
|
|
28
|
+
|
|
29
|
+
return instance, {}
|
|
30
|
+
|
|
31
|
+
except ValidationError as e:
|
|
32
|
+
errors = {}
|
|
33
|
+
for err in e.errors():
|
|
34
|
+
# Extract field name. 'loc' is a tuple like ('field',).
|
|
35
|
+
# Nested fields might be ('parent', 'child').
|
|
36
|
+
# We want to map this back to dotted string for errors dict.
|
|
37
|
+
loc = err.get("loc", ())
|
|
38
|
+
field_name = ".".join(str(part) for part in loc)
|
|
39
|
+
|
|
40
|
+
# Simple error message
|
|
41
|
+
msg = err.get("msg", "Invalid value")
|
|
42
|
+
|
|
43
|
+
# Remove Pydantic's "Value error, " prefix if present
|
|
44
|
+
if msg.startswith("Value error, "):
|
|
45
|
+
msg = msg[len("Value error, ") :]
|
|
46
|
+
|
|
47
|
+
errors[field_name] = msg
|
|
48
|
+
|
|
49
|
+
return None, errors
|
|
50
|
+
except Exception as e:
|
|
51
|
+
# Unexpected error during validation
|
|
52
|
+
return None, {"__all__": str(e)}
|
pywire/runtime/router.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Routing system."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple, Type
|
|
5
|
+
|
|
6
|
+
from pywire.runtime.page import BasePage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Route:
|
|
10
|
+
"""Represents a single route pattern."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self, pattern: str, page_class: Type[BasePage], name: Optional[str]
|
|
14
|
+
) -> None:
|
|
15
|
+
self.pattern = pattern
|
|
16
|
+
self.page_class = page_class
|
|
17
|
+
self.name = name
|
|
18
|
+
|
|
19
|
+
# Compile pattern to regex
|
|
20
|
+
self.regex = self._compile_pattern(pattern)
|
|
21
|
+
|
|
22
|
+
def _compile_pattern(self, pattern: str) -> re.Pattern:
|
|
23
|
+
"""Convert '/projects/:id:int' to regex."""
|
|
24
|
+
if pattern == "/":
|
|
25
|
+
return re.compile(r"^/$")
|
|
26
|
+
|
|
27
|
+
# 1. Normalize :param syntax to {param} for internal processing if needed,
|
|
28
|
+
# or just process directly. Let's process :param directly.
|
|
29
|
+
# Supported format: :name or :name:type or {name} or {name:type}
|
|
30
|
+
|
|
31
|
+
# We need to handle both :param and {param} syntax
|
|
32
|
+
# Let's standardize on one before regex gen or handle both in regex replacement
|
|
33
|
+
|
|
34
|
+
# Replace placeholders with regex groups
|
|
35
|
+
# We look for two patterns:
|
|
36
|
+
# 1. :name(:type)?
|
|
37
|
+
# 2. \{name(:type)?\}
|
|
38
|
+
|
|
39
|
+
# Helper to generate regex for a type
|
|
40
|
+
def get_type_regex(type_name: str) -> str:
|
|
41
|
+
if type_name == "int":
|
|
42
|
+
return r"\d+"
|
|
43
|
+
elif type_name == "str":
|
|
44
|
+
return r"[^/]+"
|
|
45
|
+
# Default to string
|
|
46
|
+
return r"[^/]+"
|
|
47
|
+
|
|
48
|
+
# This logic is a bit complex for a single regex replace.
|
|
49
|
+
# Let's manually parse/split the string or use a strict regex.
|
|
50
|
+
|
|
51
|
+
# Let's use a tokenizing approach for robustness,
|
|
52
|
+
# or a series of regex replacements that don't conflict.
|
|
53
|
+
|
|
54
|
+
# Tokenizing approach for robustness
|
|
55
|
+
# 1. Split by '/'
|
|
56
|
+
# 2. Process segments
|
|
57
|
+
# 3. Join
|
|
58
|
+
|
|
59
|
+
parts = pattern.split("/")
|
|
60
|
+
regex_parts = []
|
|
61
|
+
|
|
62
|
+
for part in parts:
|
|
63
|
+
if not part:
|
|
64
|
+
# Empty part (e.g. start of string)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Check for :param
|
|
68
|
+
if part.startswith(":"):
|
|
69
|
+
# :id or :id:int
|
|
70
|
+
content = part[1:]
|
|
71
|
+
if ":" in content:
|
|
72
|
+
name, type_name = content.split(":", 1)
|
|
73
|
+
else:
|
|
74
|
+
name, type_name = content, "str"
|
|
75
|
+
|
|
76
|
+
regex = get_type_regex(type_name)
|
|
77
|
+
regex_parts.append(f"(?P<{name}>{regex})")
|
|
78
|
+
|
|
79
|
+
# Check for {param}
|
|
80
|
+
elif part.startswith("{") and part.endswith("}"):
|
|
81
|
+
content = part[1:-1]
|
|
82
|
+
if ":" in content:
|
|
83
|
+
name, type_name = content.split(":", 1)
|
|
84
|
+
else:
|
|
85
|
+
name, type_name = content, "str"
|
|
86
|
+
|
|
87
|
+
regex = get_type_regex(type_name)
|
|
88
|
+
regex_parts.append(f"(?P<{name}>{regex})")
|
|
89
|
+
|
|
90
|
+
else:
|
|
91
|
+
# Literal
|
|
92
|
+
regex_parts.append(re.escape(part))
|
|
93
|
+
|
|
94
|
+
regex_str = "^/" + "/".join(regex_parts) + "$"
|
|
95
|
+
return re.compile(regex_str)
|
|
96
|
+
|
|
97
|
+
def match(self, path: str) -> Optional[dict[str, str]]:
|
|
98
|
+
"""Try to match path, return params if successful."""
|
|
99
|
+
match = self.regex.match(path)
|
|
100
|
+
if match:
|
|
101
|
+
# We need to convert types!
|
|
102
|
+
params = match.groupdict()
|
|
103
|
+
# Since we baked types into regex (e.g. \d+), we know they match format.
|
|
104
|
+
# But we should cast them to Python types.
|
|
105
|
+
|
|
106
|
+
# For this MVP, we might just return strings,
|
|
107
|
+
# BUT the user asked for ":id:int matches /test/2", which implies type checking
|
|
108
|
+
# Match only digits. The generated params dict currently contains strings.
|
|
109
|
+
# We should probably convert them if we have the type info available.
|
|
110
|
+
|
|
111
|
+
# Re-parsing pattern to get types is inefficient.
|
|
112
|
+
# Let's assume for now they want the matching behavior.
|
|
113
|
+
# BasePage expects params: Dict[str, str].
|
|
114
|
+
# If we change that, we break compatibility or need to update BasePage.
|
|
115
|
+
# User requirement: "params['id'] is guaranteed to be populated".
|
|
116
|
+
# Doesn't explicitly demand it be an int object, but ":int" suggests validation.
|
|
117
|
+
# Given dynamic nature, let's keep as strings in params for now to
|
|
118
|
+
# satisfy Dict[str, str] hint, or update hint in BasePage.
|
|
119
|
+
|
|
120
|
+
return params
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class URLHelper:
|
|
125
|
+
"""Helper to generate URLs."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, routes: Dict[str, str]) -> None:
|
|
128
|
+
self.routes = routes
|
|
129
|
+
|
|
130
|
+
def __getitem__(self, key: str) -> "URLTemplate":
|
|
131
|
+
if key not in self.routes:
|
|
132
|
+
raise KeyError(f"Route variant '{key}' not found")
|
|
133
|
+
return URLTemplate(self.routes[key])
|
|
134
|
+
|
|
135
|
+
def __str__(self) -> str:
|
|
136
|
+
# Return dict with normalized patterns
|
|
137
|
+
import re
|
|
138
|
+
|
|
139
|
+
def normalize_pattern(pattern: str) -> str:
|
|
140
|
+
def replace_param(match: re.Match) -> str:
|
|
141
|
+
return f"{{{match.group(1)}}}"
|
|
142
|
+
|
|
143
|
+
cleaned = re.sub(r":(\w+)(:\w+)?", replace_param, pattern)
|
|
144
|
+
cleaned = re.sub(r"\{(\w+)(:\w+)?\}", replace_param, cleaned)
|
|
145
|
+
return cleaned
|
|
146
|
+
|
|
147
|
+
normalized = {k: normalize_pattern(v) for k, v in self.routes.items()}
|
|
148
|
+
return str(normalized)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class URLTemplate:
|
|
152
|
+
"""Wraps a route pattern to allow .format()."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, pattern: str) -> None:
|
|
155
|
+
self.pattern = pattern
|
|
156
|
+
|
|
157
|
+
def format(self, **kwargs: Any) -> str:
|
|
158
|
+
url = self.pattern
|
|
159
|
+
# Simple replacement for now.
|
|
160
|
+
# Needs to handle :param syntax conversion to {param} for format,
|
|
161
|
+
# or custom formatter.
|
|
162
|
+
|
|
163
|
+
# Regex to find params in pattern:
|
|
164
|
+
# 1. {name:type} or {name}
|
|
165
|
+
# 2. :name:type or :name
|
|
166
|
+
# Note: We must be careful not to match the :type part of {name:type} as a :name.
|
|
167
|
+
# We can do this by matching the more specific {name:type} first in an OR,
|
|
168
|
+
# or by using a single regex for both.
|
|
169
|
+
|
|
170
|
+
pattern = r"\{(\w+)(?::\w+)?\}|:(\w+)(?::\w+)?"
|
|
171
|
+
|
|
172
|
+
def replace_match(match: re.Match) -> str:
|
|
173
|
+
# Group 1 is from {}, Group 2 is from :
|
|
174
|
+
name = match.group(1) or match.group(2)
|
|
175
|
+
return f"{{{name}}}"
|
|
176
|
+
|
|
177
|
+
return re.sub(pattern, replace_match, url).format(**kwargs)
|
|
178
|
+
|
|
179
|
+
def __str__(self) -> str:
|
|
180
|
+
# Return normalized pattern with {param} instead of :param
|
|
181
|
+
pattern = r"\{(\w+)(?::\w+)?\}|:(\w+)(?::\w+)?"
|
|
182
|
+
|
|
183
|
+
def replace_match(match: re.Match) -> str:
|
|
184
|
+
name = match.group(1) or match.group(2)
|
|
185
|
+
return f"{{{name}}}"
|
|
186
|
+
|
|
187
|
+
return re.sub(pattern, replace_match, self.pattern)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Router:
|
|
191
|
+
"""Routes requests to page classes based on !path directives."""
|
|
192
|
+
|
|
193
|
+
def __init__(self) -> None:
|
|
194
|
+
self.routes: list[Route] = []
|
|
195
|
+
|
|
196
|
+
def add_route(
|
|
197
|
+
self, pattern: str, page_class: Type[BasePage], name: Optional[str] = None
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Add route from compiled page."""
|
|
200
|
+
self.routes.append(Route(pattern, page_class, name))
|
|
201
|
+
|
|
202
|
+
def add_page(self, page_class: Type[BasePage]) -> None:
|
|
203
|
+
"""Register all routes for a page class."""
|
|
204
|
+
if hasattr(page_class, "__routes__"):
|
|
205
|
+
for name, pattern in page_class.__routes__.items():
|
|
206
|
+
self.add_route(pattern, page_class, name)
|
|
207
|
+
elif hasattr(page_class, "__route__"):
|
|
208
|
+
self.add_route(page_class.__route__, page_class)
|
|
209
|
+
|
|
210
|
+
def match(
|
|
211
|
+
self, path: str
|
|
212
|
+
) -> Optional[Tuple[Type[BasePage], dict[str, str], Optional[str]]]:
|
|
213
|
+
"""Match URL path to page class. Returns: (PageClass, params, variant_name)."""
|
|
214
|
+
for route in self.routes:
|
|
215
|
+
params = route.match(path)
|
|
216
|
+
if params is not None:
|
|
217
|
+
return (route.page_class, params, route.name)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def remove_routes_for_file(self, file_path: str) -> None:
|
|
221
|
+
"""Remove all routes associated with a file path."""
|
|
222
|
+
# Normalize file path for comparison
|
|
223
|
+
file_path = str(file_path)
|
|
224
|
+
|
|
225
|
+
self.routes = [
|
|
226
|
+
r
|
|
227
|
+
for r in self.routes
|
|
228
|
+
if getattr(r.page_class, "__file_path__", "") != file_path
|
|
229
|
+
]
|
pywire/runtime/server.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Server factory for uvicorn."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from pywire.runtime.app import PyWire
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_app(pages_dir: Optional[Path] = None, reload: bool = False) -> Any:
|
|
10
|
+
"""Create ASGI app - used by uvicorn."""
|
|
11
|
+
if pages_dir is None:
|
|
12
|
+
# Default pages directory
|
|
13
|
+
pages_dir = Path("pages")
|
|
14
|
+
|
|
15
|
+
# Try to find pages directory
|
|
16
|
+
if not pages_dir.exists():
|
|
17
|
+
# Try src/pages
|
|
18
|
+
pages_dir = Path("src/pages")
|
|
19
|
+
|
|
20
|
+
app = PyWire(str(pages_dir))
|
|
21
|
+
# Store references to handlers in state for dev server access
|
|
22
|
+
app.app.state.ws_handler = app.ws_handler
|
|
23
|
+
app.app.state.http_handler = app.http_handler
|
|
24
|
+
app.app.state.pywire_app = app
|
|
25
|
+
return app.app
|