pywire 0.1.1__py3-none-any.whl → 0.1.3__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 (103) hide show
  1. pywire/__init__.py +2 -0
  2. pywire/cli/__init__.py +1 -0
  3. pywire/cli/generators.py +48 -0
  4. pywire/cli/main.py +309 -0
  5. pywire/cli/tui.py +563 -0
  6. pywire/cli/validate.py +26 -0
  7. pywire/client/.prettierignore +8 -0
  8. pywire/client/.prettierrc +7 -0
  9. pywire/client/build.mjs +73 -0
  10. pywire/client/eslint.config.js +46 -0
  11. pywire/client/package.json +39 -0
  12. pywire/client/pnpm-lock.yaml +2971 -0
  13. pywire/client/src/core/app.ts +263 -0
  14. pywire/client/src/core/dom-updater.test.ts +78 -0
  15. pywire/client/src/core/dom-updater.ts +321 -0
  16. pywire/client/src/core/index.ts +5 -0
  17. pywire/client/src/core/transport-manager.test.ts +179 -0
  18. pywire/client/src/core/transport-manager.ts +159 -0
  19. pywire/client/src/core/transports/base.ts +122 -0
  20. pywire/client/src/core/transports/http.ts +142 -0
  21. pywire/client/src/core/transports/index.ts +13 -0
  22. pywire/client/src/core/transports/websocket.ts +97 -0
  23. pywire/client/src/core/transports/webtransport.ts +149 -0
  24. pywire/client/src/dev/dev-app.ts +93 -0
  25. pywire/client/src/dev/error-trace.test.ts +97 -0
  26. pywire/client/src/dev/error-trace.ts +76 -0
  27. pywire/client/src/dev/index.ts +4 -0
  28. pywire/client/src/dev/status-overlay.ts +63 -0
  29. pywire/client/src/events/handler.test.ts +318 -0
  30. pywire/client/src/events/handler.ts +454 -0
  31. pywire/client/src/pywire.core.ts +22 -0
  32. pywire/client/src/pywire.dev.ts +27 -0
  33. pywire/client/tsconfig.json +17 -0
  34. pywire/client/vitest.config.ts +15 -0
  35. pywire/compiler/__init__.py +6 -0
  36. pywire/compiler/ast_nodes.py +304 -0
  37. pywire/compiler/attributes/__init__.py +6 -0
  38. pywire/compiler/attributes/base.py +24 -0
  39. pywire/compiler/attributes/conditional.py +37 -0
  40. pywire/compiler/attributes/events.py +55 -0
  41. pywire/compiler/attributes/form.py +37 -0
  42. pywire/compiler/attributes/loop.py +75 -0
  43. pywire/compiler/attributes/reactive.py +34 -0
  44. pywire/compiler/build.py +28 -0
  45. pywire/compiler/build_artifacts.py +342 -0
  46. pywire/compiler/codegen/__init__.py +5 -0
  47. pywire/compiler/codegen/attributes/__init__.py +6 -0
  48. pywire/compiler/codegen/attributes/base.py +19 -0
  49. pywire/compiler/codegen/attributes/events.py +35 -0
  50. pywire/compiler/codegen/directives/__init__.py +6 -0
  51. pywire/compiler/codegen/directives/base.py +16 -0
  52. pywire/compiler/codegen/directives/path.py +53 -0
  53. pywire/compiler/codegen/generator.py +2341 -0
  54. pywire/compiler/codegen/template.py +2178 -0
  55. pywire/compiler/directives/__init__.py +7 -0
  56. pywire/compiler/directives/base.py +20 -0
  57. pywire/compiler/directives/component.py +33 -0
  58. pywire/compiler/directives/context.py +93 -0
  59. pywire/compiler/directives/layout.py +49 -0
  60. pywire/compiler/directives/no_spa.py +24 -0
  61. pywire/compiler/directives/path.py +71 -0
  62. pywire/compiler/directives/props.py +88 -0
  63. pywire/compiler/exceptions.py +19 -0
  64. pywire/compiler/interpolation/__init__.py +6 -0
  65. pywire/compiler/interpolation/base.py +28 -0
  66. pywire/compiler/interpolation/jinja.py +272 -0
  67. pywire/compiler/parser.py +750 -0
  68. pywire/compiler/paths.py +29 -0
  69. pywire/compiler/preprocessor.py +43 -0
  70. pywire/core/wire.py +119 -0
  71. pywire/py.typed +0 -0
  72. pywire/runtime/__init__.py +7 -0
  73. pywire/runtime/aioquic_server.py +194 -0
  74. pywire/runtime/app.py +901 -0
  75. pywire/runtime/compile_error_page.py +195 -0
  76. pywire/runtime/debug.py +203 -0
  77. pywire/runtime/dev_server.py +433 -0
  78. pywire/runtime/dev_server.py.broken +268 -0
  79. pywire/runtime/error_page.py +64 -0
  80. pywire/runtime/error_renderer.py +23 -0
  81. pywire/runtime/escape.py +23 -0
  82. pywire/runtime/files.py +40 -0
  83. pywire/runtime/helpers.py +97 -0
  84. pywire/runtime/http_transport.py +253 -0
  85. pywire/runtime/loader.py +272 -0
  86. pywire/runtime/logging.py +72 -0
  87. pywire/runtime/page.py +384 -0
  88. pywire/runtime/pydantic_integration.py +52 -0
  89. pywire/runtime/router.py +229 -0
  90. pywire/runtime/server.py +25 -0
  91. pywire/runtime/style_collector.py +31 -0
  92. pywire/runtime/upload_manager.py +76 -0
  93. pywire/runtime/validation.py +449 -0
  94. pywire/runtime/websocket.py +665 -0
  95. pywire/runtime/webtransport_handler.py +195 -0
  96. pywire/static/pywire.core.min.js +3 -0
  97. pywire/static/pywire.dev.min.js +20 -0
  98. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/METADATA +1 -1
  99. pywire-0.1.3.dist-info/RECORD +106 -0
  100. pywire-0.1.1.dist-info/RECORD +0 -9
  101. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/WHEEL +0 -0
  102. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/entry_points.txt +0 -0
  103. {pywire-0.1.1.dist-info → pywire-0.1.3.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)}
@@ -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
+ ]
@@ -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