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.
Files changed (101) 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 +434 -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-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
  97. pywire-0.1.2.dist-info/RECORD +104 -0
  98. pywire-0.1.1.dist-info/RECORD +0 -9
  99. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
  100. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
  101. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2341 @@
1
+ """Main code generator orchestrator."""
2
+
3
+ import ast
4
+ import re
5
+ from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union, cast
6
+
7
+ from pywire.compiler.ast_nodes import (
8
+ ComponentDirective,
9
+ Directive,
10
+ EventAttribute,
11
+ FormValidationSchema,
12
+ InjectDirective,
13
+ LayoutDirective,
14
+ NoSpaDirective,
15
+ ParsedPyWire,
16
+ PathDirective,
17
+ PropsDirective,
18
+ ProvideDirective,
19
+ SpecialAttribute,
20
+ TemplateNode,
21
+ )
22
+ from pywire.compiler.codegen.attributes.base import AttributeCodegen
23
+ from pywire.compiler.codegen.attributes.events import EventAttributeCodegen
24
+ from pywire.compiler.codegen.directives.base import DirectiveCodegen
25
+ from pywire.compiler.codegen.directives.path import PathDirectiveCodegen
26
+ from pywire.compiler.codegen.template import TemplateCodegen
27
+
28
+
29
+ class CodeGenerator:
30
+ """Generates Python module from ParsedPyWire AST."""
31
+
32
+ def __init__(self) -> None:
33
+ self.directive_handlers: Dict[Type[Directive], DirectiveCodegen] = {
34
+ PathDirective: PathDirectiveCodegen(),
35
+ # Future: LayoutDirective: LayoutDirectiveCodegen(), etc.
36
+ }
37
+
38
+ self.attribute_handlers: Dict[Type[SpecialAttribute], AttributeCodegen] = {
39
+ EventAttribute: EventAttributeCodegen(),
40
+ # Future: BindAttribute: BindAttributeCodegen(), etc.
41
+ }
42
+
43
+ self.template_codegen = TemplateCodegen()
44
+
45
+ def _generate_component_loading(
46
+ self, parsed: ParsedPyWire
47
+ ) -> Tuple[List[ast.stmt], Dict[str, str]]:
48
+ """
49
+ Generate code to load components and return Tag -> ClassName map.
50
+ Returns: (stmts, component_map)
51
+ """
52
+ stmts: List[ast.stmt] = []
53
+ component_map = {}
54
+
55
+ for directive in parsed.directives:
56
+ if isinstance(directive, ComponentDirective):
57
+ # Name = load_component("path", __file_path__)
58
+
59
+ # Check for "as Name" collision with imports or other components?
60
+ # Python handles it (overwrite), but maybe warn?
61
+
62
+ target_name = directive.component_name
63
+ path = directive.path
64
+
65
+ # component_map[target_name] = target_name (class is assigned to this var)
66
+ # Parse lowercases HTML tags, so we map lowercase name to actual class name
67
+ component_map[target_name.lower()] = target_name
68
+
69
+ stmts.append(
70
+ ast.Assign(
71
+ targets=[ast.Name(id=target_name, ctx=ast.Store())],
72
+ value=ast.Call(
73
+ func=ast.Name(id="load_component", ctx=ast.Load()),
74
+ args=[
75
+ ast.Constant(value=path),
76
+ ast.Constant(value=parsed.file_path),
77
+ ],
78
+ keywords=[],
79
+ ),
80
+ )
81
+ )
82
+
83
+ return stmts, component_map
84
+
85
+ def generate(self, parsed: ParsedPyWire) -> ast.Module:
86
+ """Generate complete module AST."""
87
+ self.file_path = parsed.file_path
88
+ self._has_top_level_init = False
89
+ self._collected_mount_hooks: List[str] = []
90
+ module_body = []
91
+
92
+ # Imports
93
+ module_body.extend(self._generate_imports())
94
+
95
+ # Add asyncio import for handle_event
96
+ module_body.append(ast.Import(names=[ast.alias(name="asyncio", asname=None)]))
97
+
98
+ # Component loading (early, so they are available)
99
+ comp_stmts, component_map = self._generate_component_loading(parsed)
100
+ module_body.extend(comp_stmts)
101
+
102
+ # Layout logic
103
+ layout_directive = parsed.get_directive_by_type(LayoutDirective)
104
+
105
+ if layout_directive:
106
+ layout_directive = cast(LayoutDirective, layout_directive)
107
+ # Import load_layout
108
+ module_body.append(
109
+ ast.ImportFrom(
110
+ module="pywire.runtime.loader",
111
+ names=[ast.alias(name="load_layout", asname=None)],
112
+ level=0,
113
+ )
114
+ )
115
+ # Load layout class
116
+ # _LayoutBase = load_layout("path", __file_path__)
117
+ module_body.append(
118
+ ast.Assign(
119
+ targets=[ast.Name(id="_LayoutBase", ctx=ast.Store())],
120
+ value=ast.Call(
121
+ func=ast.Name(id="load_layout", ctx=ast.Load()),
122
+ args=[
123
+ ast.Constant(value=layout_directive.layout_path),
124
+ ast.Constant(
125
+ value=parsed.file_path
126
+ ), # Pass page file path for relative resolution
127
+ ],
128
+ keywords=[],
129
+ ),
130
+ )
131
+ )
132
+ # Extract user imports from Python section
133
+ if parsed.python_ast:
134
+ module_body.extend(self._extract_user_imports(parsed.python_ast))
135
+ # Extract user classes to module level (Pydantic models, etc.)
136
+ module_body.extend(self._extract_user_classes(parsed.python_ast))
137
+
138
+ # Extract method names early for binding logic
139
+ known_methods, known_vars, async_methods = self._collect_global_names(
140
+ parsed.python_ast
141
+ )
142
+
143
+ # Include explicit variable assignments
144
+ known_vars.update(self._extract_user_variables(parsed.python_ast))
145
+
146
+ known_imports = self._extract_import_names(parsed.python_ast)
147
+ all_globals = known_methods.union(known_vars).union(known_imports)
148
+
149
+ # Inline handlers (with method names)
150
+ # Note: Handlers only need to know about globals to avoid "self." prefixing if needed,
151
+ # but _process_handlers mostly cares about wrapping logic.
152
+ # Actually _process_handlers calls _transform_inline_code which uses known_methods.
153
+ # Ideally it should know about all globals too.
154
+ handlers, allowed_handlers = self._process_handlers(
155
+ parsed, all_globals, async_methods
156
+ )
157
+
158
+ # Page class
159
+ page_class = self._generate_page_class(
160
+ parsed,
161
+ handlers,
162
+ known_methods,
163
+ known_vars,
164
+ known_imports,
165
+ async_methods,
166
+ component_map,
167
+ allowed_handlers,
168
+ )
169
+ module_body.append(page_class)
170
+
171
+ # Export reference to main class
172
+ module_body.append(
173
+ ast.Assign(
174
+ targets=[ast.Name(id="__page_class__", ctx=ast.Store())],
175
+ value=ast.Name(id=page_class.name, ctx=ast.Load()),
176
+ )
177
+ )
178
+
179
+ module = ast.Module(body=module_body, type_ignores=[])
180
+ ast.fix_missing_locations(module)
181
+
182
+ return module
183
+
184
+ def _generate_imports(self) -> List[ast.stmt]:
185
+ """Generate framework imports."""
186
+ imports: List[ast.stmt] = [
187
+ ast.ImportFrom(
188
+ module="pywire.runtime.page",
189
+ names=[ast.alias(name="BasePage", asname=None)],
190
+ level=0,
191
+ ),
192
+ ast.ImportFrom(
193
+ module="pywire.core.wire",
194
+ names=[ast.alias(name="wire", asname=None)],
195
+ level=0,
196
+ ),
197
+ ast.ImportFrom(
198
+ module="starlette.responses",
199
+ names=[ast.alias(name="Response", asname=None)],
200
+ level=0,
201
+ ),
202
+ ast.Import(names=[ast.alias(name="json", asname=None)]),
203
+ # Form validation imports
204
+ ast.ImportFrom(
205
+ module="pywire.runtime.validation",
206
+ names=[
207
+ ast.alias(name="form_validator", asname=None),
208
+ ast.alias(name="FieldRules", asname=None),
209
+ ast.alias(name="FormValidationSchema", asname=None),
210
+ ],
211
+ level=0,
212
+ ),
213
+ ast.ImportFrom(
214
+ module="pywire.runtime.pydantic_integration",
215
+ names=[ast.alias(name="validate_with_model", asname=None)],
216
+ level=0,
217
+ ),
218
+ ast.ImportFrom(
219
+ module="pywire.runtime.loader",
220
+ names=[ast.alias(name="load_component", asname=None)],
221
+ level=0,
222
+ ),
223
+ ast.ImportFrom(
224
+ module="pywire.runtime.helpers",
225
+ names=[
226
+ ast.alias(name="unwrap_wire", asname=None),
227
+ ast.alias(name="set_render_context", asname=None),
228
+ ast.alias(name="reset_render_context", asname=None),
229
+ ],
230
+ level=0,
231
+ ),
232
+ ]
233
+ return imports
234
+
235
+ def _generate_component_imports(
236
+ self, parsed: ParsedPyWire
237
+ ) -> Tuple[List[ast.stmt], Dict[str, str]]:
238
+ """
239
+ Generate imports for components and return a map of TagName -> ClassName.
240
+ Returns: (import_stmts, component_map)
241
+ """
242
+ imports: List[ast.stmt] = []
243
+ component_map: Dict[str, str] = {}
244
+
245
+ from pywire.compiler.ast_nodes import ComponentDirective
246
+
247
+ for directive in parsed.directives:
248
+ if isinstance(directive, ComponentDirective):
249
+ # !component 'path' as Name
250
+ # We need to resolve 'path' to a python module path.
251
+ # Assuming 'path' is relative to project root or use loader helper?
252
+ # Actually, generated code will run in server context.
253
+ # Better to use our dynamic loader:
254
+ # Name = load_component('path', __file_path__)
255
+
256
+ # Import load_component if not already
257
+ # (handled in _generate_imports? No, let's assume we import a loader helper)
258
+
259
+ # We'll generate:
260
+ # Name = load_component("path", __file_path__)
261
+ # But imports are usually at module level.
262
+ # If we use load_component, it's an assignment, not an import.
263
+ # That's fine, we can add it to module body.
264
+
265
+ # However, cleaner if we can generate `from x import Y`.
266
+ # But we don't know the exact class name inside the file (it's generated).
267
+ # The dynamic loader `load_component` is robust.
268
+
269
+ # Let's add `load_component` to imports
270
+ pass
271
+
272
+ return imports, component_map
273
+
274
+ def _extract_user_imports(self, python_ast: ast.Module) -> List[ast.stmt]:
275
+ """Extract import statements from user Python code."""
276
+ imports: List[ast.stmt] = []
277
+ for node in python_ast.body:
278
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
279
+ imports.append(node)
280
+ return imports
281
+
282
+ def _extract_user_classes(self, python_ast: ast.Module) -> List[ast.stmt]:
283
+ """Extract class definitions from user Python code."""
284
+ classes: List[ast.stmt] = []
285
+ for node in python_ast.body:
286
+ if isinstance(node, ast.ClassDef):
287
+ classes.append(node)
288
+ return classes
289
+
290
+ def _extract_import_names(self, python_ast: Optional[ast.Module]) -> Set[str]:
291
+ """Extract names defined by imports."""
292
+ names = set()
293
+ # Add default imports
294
+ names.add("json")
295
+ names.add("form_validator")
296
+ names.add("FieldRules")
297
+
298
+ if python_ast:
299
+ for node in python_ast.body:
300
+ if isinstance(node, ast.Import):
301
+ for alias in node.names:
302
+ names.add(alias.asname or alias.name)
303
+ elif isinstance(node, ast.ImportFrom):
304
+ for alias in node.names:
305
+ names.add(alias.asname or alias.name)
306
+ return names
307
+
308
+ def _extract_user_variables(self, python_ast: Optional[ast.Module]) -> Set[str]:
309
+ """Extract variable names assigned at the top level of user code."""
310
+ vars: Set[str] = set()
311
+ if not python_ast:
312
+ return vars
313
+
314
+ for node in python_ast.body:
315
+ if isinstance(node, ast.Assign):
316
+ for target in node.targets:
317
+ if isinstance(target, ast.Name):
318
+ vars.add(target.id)
319
+ elif isinstance(node, ast.AnnAssign):
320
+ if isinstance(node.target, ast.Name):
321
+ vars.add(node.target.id)
322
+ return vars
323
+
324
+ def _extract_route_params_from_pattern(self, pattern: str) -> Set[str]:
325
+ params: Set[str] = set()
326
+ if not pattern:
327
+ return params
328
+
329
+ for name in re.findall(r"\{([a-zA-Z_]\w*)(?::[^}]+)?\}", pattern):
330
+ if not name.isidentifier():
331
+ continue
332
+ params.add(name)
333
+
334
+ for name in re.findall(r":([a-zA-Z_]\w*)(?::[^/]+)?", pattern):
335
+ if not name.isidentifier():
336
+ continue
337
+ params.add(name)
338
+
339
+ return params
340
+
341
+ def _extract_route_params_from_file_path(
342
+ self, file_path: Optional[str]
343
+ ) -> Set[str]:
344
+ params: Set[str] = set()
345
+ if not file_path:
346
+ return params
347
+
348
+ from pathlib import Path
349
+
350
+ path = Path(file_path)
351
+
352
+ for part in path.parts:
353
+ if not part:
354
+ continue
355
+ if not (part.startswith("[") and part.endswith("]")):
356
+ continue
357
+ name = part[1:-1]
358
+ if not name:
359
+ continue
360
+ if not name.isidentifier():
361
+ continue
362
+ params.add(name)
363
+
364
+ stem = path.stem
365
+ if stem.startswith("[") and stem.endswith("]"):
366
+ name = stem[1:-1]
367
+ if name and name.isidentifier():
368
+ params.add(name)
369
+
370
+ return params
371
+
372
+ def _extract_route_params(self, parsed: ParsedPyWire) -> Set[str]:
373
+ params: Set[str] = set()
374
+
375
+ path_directive = parsed.get_directive_by_type(PathDirective)
376
+ if path_directive:
377
+ assert isinstance(path_directive, PathDirective)
378
+ for pattern in path_directive.routes.values():
379
+ params.update(self._extract_route_params_from_pattern(pattern))
380
+
381
+ params.update(self._extract_route_params_from_file_path(parsed.file_path))
382
+
383
+ return params
384
+
385
+ def _generate_page_class(
386
+ self,
387
+ parsed: ParsedPyWire,
388
+ handlers: List[ast.AsyncFunctionDef],
389
+ known_methods: Set[str],
390
+ known_vars: Set[str],
391
+ known_imports: Set[str],
392
+ async_methods: Set[str],
393
+ component_map: Dict[str, str],
394
+ allowed_handlers: Optional[Set[str]] = None,
395
+ ) -> ast.ClassDef:
396
+ """Generate page class definition."""
397
+ class_body: List[ast.stmt] = []
398
+
399
+ # Add generated handlers
400
+ class_body.extend(handlers)
401
+
402
+ # Generate __allowed_handlers__ for security (prevents arbitrary method invocation)
403
+ if allowed_handlers is None:
404
+ allowed_handlers = set()
405
+ # Include _handle_bind_* handlers that may be generated later
406
+ # These will be added dynamically, but we pre-allow the pattern
407
+ class_body.append(
408
+ ast.Assign(
409
+ targets=[ast.Name(id="__allowed_handlers__", ctx=ast.Store())],
410
+ value=ast.Set(
411
+ elts=[ast.Constant(value=h) for h in sorted(allowed_handlers)]
412
+ )
413
+ if allowed_handlers
414
+ else ast.Call(
415
+ func=ast.Name(id="set", ctx=ast.Load()), args=[], keywords=[]
416
+ ),
417
+ )
418
+ )
419
+
420
+ # Generate directive assignments (e.g., __routes__)
421
+ for directive in parsed.directives:
422
+ handler = self.directive_handlers.get(type(directive))
423
+ if handler:
424
+ class_body.extend(handler.generate(directive))
425
+
426
+ # Generate SPA navigation metadata
427
+ class_body.extend(self._generate_spa_metadata(parsed))
428
+
429
+ # Inject __no_spa__ flag if !no_spa was detected
430
+ if parsed.get_directive_by_type(NoSpaDirective):
431
+ class_body.append(
432
+ ast.Assign(
433
+ targets=[ast.Name(id="__no_spa__", ctx=ast.Store())],
434
+ value=ast.Constant(value=True),
435
+ )
436
+ )
437
+
438
+ # Transform user Python code to class methods (Must run before __init__ to set flags)
439
+ route_params = self._extract_route_params(parsed)
440
+ all_globals = known_methods.union(known_vars).union(route_params)
441
+ user_code_stmts: List[ast.stmt] = []
442
+ if parsed.python_ast:
443
+ user_code_stmts = self._transform_user_code(parsed.python_ast, all_globals)
444
+
445
+ # Generate __init__ method
446
+ class_body.append(self._generate_init_method(parsed))
447
+
448
+ # Add user code
449
+ class_body.extend(user_code_stmts)
450
+
451
+ # Generate form validation schemas and wrappers
452
+ # MUST happen before render generation as it updates EventAttributes to point to wrappers
453
+ form_validation_methods = self._generate_form_validation_methods(
454
+ parsed, all_globals
455
+ )
456
+ class_body.extend(form_validation_methods)
457
+ # Generate _render_template method AND binding methods
458
+ # Pass ALL globals to avoid auto-calling variables and prefixing imports
459
+ all_globals = (
460
+ known_methods.union(known_vars).union(route_params).union(known_imports)
461
+ )
462
+
463
+ render_func, binding_funcs = self._generate_render_template_method(
464
+ parsed, known_methods, all_globals, async_methods, component_map
465
+ )
466
+ if render_func:
467
+ class_body.append(render_func)
468
+ class_body.extend(binding_funcs)
469
+
470
+ # Inject __has_uploads__ flag if file inputs were detected
471
+ if self.template_codegen.has_file_inputs:
472
+ class_body.append(
473
+ ast.Assign(
474
+ targets=[ast.Name(id="__has_uploads__", ctx=ast.Store())],
475
+ value=ast.Constant(value=True),
476
+ )
477
+ )
478
+
479
+ # Determine base class
480
+ base_id = "BasePage"
481
+ if parsed.get_directive_by_type(LayoutDirective):
482
+ base_id = "_LayoutBase"
483
+
484
+ # Inject LAYOUT_ID if we determined one is needed
485
+ # We need to calculate it here too or pass it back from _generate_render_template_method
486
+ # Since we need it for class attribute, let's calculate it early.
487
+ layout_id_to_inject = None
488
+ if parsed.file_path:
489
+ import hashlib
490
+
491
+ layout_id_hash = hashlib.md5(str(parsed.file_path).encode()).hexdigest()
492
+ # Recursive check for slots
493
+ has_slots = self._has_slots_recursive(parsed.template)
494
+ if has_slots:
495
+ layout_id_to_inject = layout_id_hash
496
+
497
+ if layout_id_to_inject:
498
+ class_body.append(
499
+ ast.Assign(
500
+ targets=[ast.Name(id="LAYOUT_ID", ctx=ast.Store())],
501
+ value=ast.Constant(value=layout_id_to_inject),
502
+ )
503
+ )
504
+
505
+ # Lifecycle hooks calculation
506
+ init_hooks = []
507
+ # If we found @mount decorated methods
508
+ if hasattr(self, "_collected_mount_hooks") and self._collected_mount_hooks:
509
+ init_hooks.extend(self._collected_mount_hooks)
510
+
511
+ # Ensure 'on_before_load' and 'on_load' are present
512
+ final_init_hooks = []
513
+
514
+ # Standard hooks - REMOVED per user request
515
+ # final_init_hooks.append('on_before_load')
516
+ # final_init_hooks.append('on_load')
517
+
518
+ # Add mount hooks
519
+ if hasattr(self, "_collected_mount_hooks") and self._collected_mount_hooks:
520
+ final_init_hooks.extend(self._collected_mount_hooks)
521
+
522
+ class_body.append(
523
+ ast.Assign(
524
+ targets=[ast.Name(id="INIT_HOOKS", ctx=ast.Store())],
525
+ value=ast.List(
526
+ elts=[ast.Constant(value=h) for h in final_init_hooks],
527
+ ctx=ast.Load(),
528
+ ),
529
+ )
530
+ )
531
+ cls_def = ast.ClassDef(
532
+ name=self._get_class_name(parsed),
533
+ bases=[ast.Name(id=base_id, ctx=ast.Load())],
534
+ keywords=[],
535
+ body=class_body,
536
+ decorator_list=[],
537
+ )
538
+ cls_def.lineno = 1
539
+ cls_def.col_offset = 0
540
+ return cls_def
541
+
542
+ def _collect_global_names(
543
+ self, python_ast: Optional[ast.Module]
544
+ ) -> Tuple[Set[str], Set[str], Set[str]]:
545
+ """Collect defined function names and variables, and identify async functions.
546
+ Returns: (method_names, variable_names, async_method_names)
547
+ """
548
+ methods = set()
549
+ variables = {
550
+ "path",
551
+ "params",
552
+ "query",
553
+ "url",
554
+ "request",
555
+ "error_code",
556
+ "error_detail",
557
+ "error_trace",
558
+ }
559
+ async_methods = set()
560
+
561
+ if python_ast:
562
+ for node in python_ast.body:
563
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
564
+ methods.add(node.name)
565
+ if isinstance(node, ast.AsyncFunctionDef):
566
+ async_methods.add(node.name)
567
+ elif isinstance(node, ast.Assign):
568
+ for target in node.targets:
569
+ for child in ast.walk(target):
570
+ if isinstance(child, ast.Name) and isinstance(
571
+ child.ctx, ast.Store
572
+ ):
573
+ variables.add(child.id)
574
+ elif isinstance(node, ast.AnnAssign):
575
+ if isinstance(node.target, ast.Name):
576
+ variables.add(node.target.id)
577
+
578
+ # Add implicit params from filename if available
579
+ if hasattr(self, "file_path") and self.file_path:
580
+ import re
581
+ from pathlib import Path
582
+
583
+ path_obj = Path(self.file_path)
584
+ # Check current file name and parent directories for [param] syntax
585
+ for part in path_obj.parts:
586
+ match = re.match(r"^\[(.*?)\]$", part.replace(".pywire", ""))
587
+ if match:
588
+ variables.add(match.group(1))
589
+
590
+ return methods, variables, async_methods
591
+
592
+ def _process_handlers(
593
+ self, parsed: ParsedPyWire, known_methods: Set[str], async_methods: Set[str]
594
+ ) -> Tuple[List[ast.AsyncFunctionDef], Set[str]]:
595
+ """Extract inline handlers and wrap handlers for bindings.
596
+
597
+ Returns:
598
+ Tuple of (handler_methods, allowed_handler_names)
599
+ """
600
+ handlers = []
601
+ allowed_handlers: Set[str] = set()
602
+ handler_count = 0
603
+ from pywire.compiler.ast_nodes import EventAttribute
604
+
605
+ def visit_nodes(nodes: List[TemplateNode]) -> None:
606
+ nonlocal handler_count
607
+ for node in nodes:
608
+ # Check for events
609
+ for attr in node.special_attributes:
610
+ if isinstance(attr, EventAttribute):
611
+ # Pre-processing: Strip wrapping braces if present (e.g. from {code} syntax)
612
+ # This ensures code inside is processed correctly whether quoted or not in
613
+ # source
614
+ raw = attr.handler_name.strip()
615
+ if raw.startswith("{") and raw.endswith("}"):
616
+ attr.handler_name = raw[1:-1].strip()
617
+
618
+ is_identifier = attr.handler_name.isidentifier()
619
+ needs_wrapper = not is_identifier
620
+
621
+ if needs_wrapper:
622
+ # Create distinct handler methods
623
+ method_name = f"_handler_{handler_count}"
624
+ handler_count += 1
625
+
626
+ try:
627
+ # Transform body logic
628
+ code_to_transform = attr.handler_name
629
+
630
+ body, args = self._transform_inline_code(
631
+ code_to_transform, known_methods, async_methods
632
+ )
633
+
634
+ # Store extracted args
635
+ attr.args = args
636
+
637
+ # Create handler method
638
+ # async def _handler_X(self, arg0, arg1...):
639
+ arg_definitions = [ast.arg(arg="self")]
640
+ for i in range(len(args)):
641
+ arg_definitions.append(ast.arg(arg=f"arg{i}"))
642
+
643
+ handlers.append(
644
+ ast.AsyncFunctionDef(
645
+ name=method_name,
646
+ args=ast.arguments(
647
+ posonlyargs=[],
648
+ args=arg_definitions,
649
+ vararg=None,
650
+ kwonlyargs=[],
651
+ kw_defaults=[],
652
+ defaults=[],
653
+ ),
654
+ body=body,
655
+ decorator_list=[],
656
+ returns=None,
657
+ )
658
+ )
659
+
660
+ attr.handler_name = method_name
661
+ # Add generated handler to allowlist
662
+ allowed_handlers.add(method_name)
663
+
664
+ except Exception as e:
665
+ print(
666
+ f"Error compiling handler '{attr.handler_name}': {e}"
667
+ )
668
+ else:
669
+ # Simple identifier handler - add to allowlist
670
+ allowed_handlers.add(attr.handler_name)
671
+
672
+ visit_nodes(node.children)
673
+
674
+ visit_nodes(parsed.template)
675
+ return handlers, allowed_handlers
676
+
677
+ def _transform_inline_code(
678
+ self,
679
+ code: str,
680
+ known_methods: Set[str] = set(),
681
+ async_methods: Set[str] = set(),
682
+ ) -> Tuple[List[ast.stmt], List[str]]:
683
+ """Transform inline code: lift arguments and prefix globals with self."""
684
+ import builtins
685
+
686
+ # Map $event to event for Alpine compatibility
687
+ code = code.replace("$event", "event")
688
+
689
+ from pywire.compiler.preprocessor import preprocess_python_code
690
+
691
+ code = preprocess_python_code(code)
692
+
693
+ tree = ast.parse(code)
694
+ extracted_args: List[str] = []
695
+
696
+ class ArgumentLifter(ast.NodeTransformer):
697
+ def visit_Call(self, node: ast.Call) -> Any:
698
+ # Check arguments for unbound variables
699
+ new_args: List[ast.expr] = []
700
+ for arg in node.args:
701
+ # Quick check: does this arg contain unbound names?
702
+ unbound = False
703
+ for child in ast.walk(arg):
704
+ if isinstance(child, ast.Name):
705
+ if child.id not in known_methods and child.id not in dir(
706
+ builtins
707
+ ):
708
+ unbound = True
709
+ break
710
+
711
+ if unbound:
712
+ # Lift it!
713
+ arg_index = len(extracted_args)
714
+ extracted_args.append(ast.unparse(arg))
715
+ new_args.append(ast.Name(id=f"arg{arg_index}", ctx=ast.Load()))
716
+ else:
717
+ new_args.append(self.visit(arg))
718
+
719
+ node.args = new_args
720
+ return self.generic_visit(node)
721
+
722
+ def visit_Name(self, node: ast.Name) -> Any:
723
+ # Transform known methods and globals to self.X
724
+ if node.id in known_methods:
725
+ return ast.Attribute(
726
+ value=ast.Name(id="self", ctx=ast.Load()),
727
+ attr=node.id,
728
+ ctx=node.ctx,
729
+ )
730
+ return node
731
+
732
+ # Run transformer
733
+ transformer = ArgumentLifter()
734
+ new_tree = transformer.visit(tree)
735
+
736
+ if async_methods:
737
+
738
+ class AsyncCallTransformer(ast.NodeTransformer):
739
+ def visit_Call(self, node: ast.Call) -> Any:
740
+ # Check if call to self.async_method
741
+ # The func is now (after ArgumentLifter/Name visit) self.method_name
742
+ if (
743
+ isinstance(node.func, ast.Attribute)
744
+ and isinstance(node.func.value, ast.Name)
745
+ and node.func.value.id == "self"
746
+ and node.func.attr in async_methods
747
+ ):
748
+ return ast.Await(value=node)
749
+ return self.generic_visit(node)
750
+
751
+ AsyncCallTransformer().visit(new_tree)
752
+
753
+ ast.fix_missing_locations(new_tree)
754
+
755
+ return new_tree.body, extracted_args
756
+
757
+ def _generate_form_validation_methods(
758
+ self, parsed: ParsedPyWire, known_globals: Set[str]
759
+ ) -> List[ast.stmt]:
760
+ """Generate validation schema and wrapper methods for forms with @submit."""
761
+ methods: List[ast.stmt] = []
762
+ form_count = 0
763
+
764
+ def visit_nodes(nodes: List[TemplateNode]) -> None:
765
+ nonlocal form_count
766
+ for node in nodes:
767
+ # Check for form with @submit that has validation schema
768
+ if node.tag and node.tag.lower() == "form":
769
+ for attr in node.special_attributes:
770
+ if (
771
+ isinstance(attr, EventAttribute)
772
+ and attr.event_type == "submit"
773
+ ):
774
+ if attr.validation_schema and attr.validation_schema.fields:
775
+ form_id = form_count
776
+ form_count += 1
777
+
778
+ # Generate validation schema as class attribute
779
+ schema_name = f"_form_schema_{form_id}"
780
+ original_handler = attr.handler_name
781
+
782
+ # Build dict literal for schema fields
783
+ schema_methods = self._generate_form_schema_literal(
784
+ attr.validation_schema, schema_name, known_globals
785
+ )
786
+ methods.extend(schema_methods)
787
+
788
+ # Generate wrapper handler
789
+ wrapper = self._generate_form_wrapper(
790
+ form_id,
791
+ original_handler,
792
+ schema_name,
793
+ attr.validation_schema,
794
+ known_globals,
795
+ )
796
+ methods.append(wrapper)
797
+
798
+ # Update handler name to point to wrapper
799
+ attr.handler_name = f"_form_submit_{form_id}"
800
+
801
+ # Recurse
802
+ visit_nodes(node.children)
803
+
804
+ visit_nodes(parsed.template)
805
+ return methods
806
+
807
+ def _generate_form_schema_literal(
808
+ self, schema: FormValidationSchema, schema_name: str, known_globals: Set[str]
809
+ ) -> List[ast.stmt]:
810
+ """Generate validation schema as a class attribute."""
811
+ field_items = []
812
+ for field_name, rules in schema.fields.items():
813
+ keywords = []
814
+
815
+ if rules.required:
816
+ keywords.append(
817
+ ast.keyword(arg="required", value=ast.Constant(value=True))
818
+ )
819
+ if rules.required_expr:
820
+ expr_ast = self.template_codegen._transform_expr(
821
+ rules.required_expr, set(), known_globals
822
+ )
823
+ expr_str = ast.unparse(expr_ast)
824
+ keywords.append(
825
+ ast.keyword(arg="required_expr", value=ast.Constant(value=expr_str))
826
+ )
827
+ if rules.pattern:
828
+ keywords.append(
829
+ ast.keyword(arg="pattern", value=ast.Constant(value=rules.pattern))
830
+ )
831
+ if rules.minlength is not None:
832
+ keywords.append(
833
+ ast.keyword(
834
+ arg="minlength", value=ast.Constant(value=rules.minlength)
835
+ )
836
+ )
837
+ if rules.maxlength is not None:
838
+ keywords.append(
839
+ ast.keyword(
840
+ arg="maxlength", value=ast.Constant(value=rules.maxlength)
841
+ )
842
+ )
843
+ if rules.min_value:
844
+ keywords.append(
845
+ ast.keyword(
846
+ arg="min_value", value=ast.Constant(value=rules.min_value)
847
+ )
848
+ )
849
+ if rules.min_expr:
850
+ expr_ast = self.template_codegen._transform_expr(
851
+ rules.min_expr, set(), known_globals
852
+ )
853
+ expr_str = ast.unparse(expr_ast)
854
+ keywords.append(
855
+ ast.keyword(arg="min_expr", value=ast.Constant(value=expr_str))
856
+ )
857
+ if rules.max_value:
858
+ keywords.append(
859
+ ast.keyword(
860
+ arg="max_value", value=ast.Constant(value=rules.max_value)
861
+ )
862
+ )
863
+ if rules.max_expr:
864
+ expr_ast = self.template_codegen._transform_expr(
865
+ rules.max_expr, set(), known_globals
866
+ )
867
+ expr_str = ast.unparse(expr_ast)
868
+ keywords.append(
869
+ ast.keyword(arg="max_expr", value=ast.Constant(value=expr_str))
870
+ )
871
+ if rules.step:
872
+ keywords.append(
873
+ ast.keyword(arg="step", value=ast.Constant(value=rules.step))
874
+ )
875
+ if rules.input_type != "text":
876
+ keywords.append(
877
+ ast.keyword(
878
+ arg="input_type", value=ast.Constant(value=rules.input_type)
879
+ )
880
+ )
881
+ if rules.title:
882
+ keywords.append(
883
+ ast.keyword(arg="title", value=ast.Constant(value=rules.title))
884
+ )
885
+ if rules.max_size is not None:
886
+ keywords.append(
887
+ ast.keyword(
888
+ arg="max_size", value=ast.Constant(value=rules.max_size)
889
+ )
890
+ )
891
+ if rules.allowed_types:
892
+ keywords.append(
893
+ ast.keyword(
894
+ arg="allowed_types",
895
+ value=ast.List(
896
+ elts=[ast.Constant(value=t) for t in rules.allowed_types],
897
+ ctx=ast.Load(),
898
+ ),
899
+ )
900
+ )
901
+
902
+ field_rules_call = ast.Call(
903
+ func=ast.Name(id="FieldRules", ctx=ast.Load()),
904
+ args=[],
905
+ keywords=keywords,
906
+ )
907
+
908
+ field_items.append((ast.Constant(value=field_name), field_rules_call))
909
+
910
+ schema_dict = ast.Dict(
911
+ keys=[k for k, v in field_items], values=[v for k, v in field_items]
912
+ )
913
+
914
+ schema_call = ast.Call(
915
+ func=ast.Name(id="FormValidationSchema", ctx=ast.Load()),
916
+ args=[],
917
+ keywords=[ast.keyword(arg="fields", value=schema_dict)],
918
+ )
919
+
920
+ if schema.model_name:
921
+ schema_call.keywords.append(
922
+ ast.keyword(
923
+ arg="model_name", value=ast.Constant(value=schema.model_name)
924
+ )
925
+ )
926
+
927
+ return [
928
+ ast.Assign(
929
+ targets=[ast.Name(id=schema_name, ctx=ast.Store())], value=schema_call
930
+ )
931
+ ]
932
+
933
+ def _generate_form_wrapper(
934
+ self,
935
+ form_id: int,
936
+ original_handler: str,
937
+ schema_name: str,
938
+ schema: FormValidationSchema,
939
+ known_globals: Set[str],
940
+ ) -> ast.AsyncFunctionDef:
941
+ """Generate wrapper handler that validates then calls original handler."""
942
+ wrapper_name = f"_form_submit_{form_id}"
943
+
944
+ # Generate:
945
+ # async def _form_submit_0(self, **kwargs):
946
+ # form_data = kwargs.get('formData', {})
947
+ #
948
+ # # Build state getter for conditional validation
949
+ # def get_state(expr):
950
+ # return eval(expr, {'self': self})
951
+ #
952
+ # # Validate
953
+ # self.errors = form_validator.validate_form(form_data, self._form_schema_0, get_state)
954
+ # if self.errors:
955
+ # return
956
+ #
957
+ # # Call original handler
958
+ # await self.original_handler(form_data)
959
+
960
+ body: List[ast.stmt] = []
961
+
962
+ # form_data = kwargs.get('formData', {})
963
+ body.append(
964
+ ast.Assign(
965
+ targets=[ast.Name(id="form_data", ctx=ast.Store())],
966
+ value=ast.Call(
967
+ func=ast.Attribute(
968
+ value=ast.Name(id="kwargs", ctx=ast.Load()),
969
+ attr="get",
970
+ ctx=ast.Load(),
971
+ ),
972
+ args=[ast.Constant(value="formData"), ast.Dict(keys=[], values=[])],
973
+ keywords=[],
974
+ ),
975
+ )
976
+ )
977
+
978
+ # Define state getter for conditional validation
979
+ # def get_state(expr):
980
+ # return eval(expr, {'self': self})
981
+ state_getter = ast.FunctionDef(
982
+ name="get_state",
983
+ args=ast.arguments(
984
+ posonlyargs=[],
985
+ args=[ast.arg(arg="expr")],
986
+ vararg=None,
987
+ kwonlyargs=[],
988
+ kw_defaults=[],
989
+ defaults=[],
990
+ ),
991
+ body=[
992
+ ast.Return(
993
+ value=ast.Call(
994
+ func=ast.Name(id="eval", ctx=ast.Load()),
995
+ args=[
996
+ ast.Name(id="expr", ctx=ast.Load()),
997
+ # Use module globals (imports, classes)
998
+ ast.Call(
999
+ func=ast.Name(id="globals", ctx=ast.Load()),
1000
+ args=[],
1001
+ keywords=[],
1002
+ ),
1003
+ # Locals: just self, because _transform_expr converts other names
1004
+ # to self.x
1005
+ ast.Dict(
1006
+ keys=[ast.Constant(value="self")],
1007
+ values=[ast.Name(id="self", ctx=ast.Load())],
1008
+ ),
1009
+ ],
1010
+ keywords=[],
1011
+ )
1012
+ )
1013
+ ],
1014
+ decorator_list=[],
1015
+ returns=None,
1016
+ )
1017
+ body.append(state_getter)
1018
+
1019
+ # cleaned_data, self.errors = form_validator.validate_form(
1020
+ # form_data, self._form_schema_X.fields, get_state
1021
+ # )
1022
+ # Note: pass .fields from the schema object
1023
+ body.append(
1024
+ ast.Assign(
1025
+ targets=[
1026
+ ast.Tuple(
1027
+ elts=[
1028
+ ast.Name(id="cleaned_data", ctx=ast.Store()),
1029
+ ast.Attribute(
1030
+ value=ast.Name(id="self", ctx=ast.Load()),
1031
+ attr="errors",
1032
+ ctx=ast.Store(),
1033
+ ),
1034
+ ],
1035
+ ctx=ast.Store(),
1036
+ )
1037
+ ],
1038
+ value=ast.Call(
1039
+ func=ast.Attribute(
1040
+ value=ast.Name(id="form_validator", ctx=ast.Load()),
1041
+ attr="validate_form",
1042
+ ctx=ast.Load(),
1043
+ ),
1044
+ args=[
1045
+ ast.Name(id="form_data", ctx=ast.Load()),
1046
+ ast.Attribute(
1047
+ value=ast.Attribute(
1048
+ value=ast.Name(id="self", ctx=ast.Load()),
1049
+ attr=schema_name,
1050
+ ctx=ast.Load(),
1051
+ ),
1052
+ attr="fields",
1053
+ ctx=ast.Load(),
1054
+ ),
1055
+ ast.Name(id="get_state", ctx=ast.Load()),
1056
+ ],
1057
+ keywords=[],
1058
+ ),
1059
+ )
1060
+ )
1061
+
1062
+ # If Pydantic model is used:
1063
+ # if not self.errors and self._form_schema_X.model_name:
1064
+ # model_instance, pydantic_errors = validate_with_model(
1065
+ # cleaned_data, globals()[self._form_schema_X.model_name]
1066
+ # )
1067
+ # if pydantic_errors:
1068
+ # self.errors.update(pydantic_errors)
1069
+ # else:
1070
+ # cleaned_data = model_instance # Replace dict with model instance
1071
+
1072
+ if schema.model_name:
1073
+ pydantic_block: List[ast.stmt] = []
1074
+
1075
+ # model_instance, pydantic_errors = validate_with_model(cleaned_data, ModelClass)
1076
+
1077
+ # PARSE NESTED DATA FIRST
1078
+ # nested_data = form_validator.parse_nested_data(cleaned_data)
1079
+ pydantic_block.append(
1080
+ ast.Assign(
1081
+ targets=[ast.Name(id="nested_data", ctx=ast.Store())],
1082
+ value=ast.Call(
1083
+ func=ast.Attribute(
1084
+ value=ast.Name(id="form_validator", ctx=ast.Load()),
1085
+ attr="parse_nested_data",
1086
+ ctx=ast.Load(),
1087
+ ),
1088
+ args=[ast.Name(id="cleaned_data", ctx=ast.Load())],
1089
+ keywords=[],
1090
+ ),
1091
+ )
1092
+ )
1093
+
1094
+ validate_call = ast.Call(
1095
+ func=ast.Name(id="validate_with_model", ctx=ast.Load()),
1096
+ args=[
1097
+ ast.Name(id="nested_data", ctx=ast.Load()),
1098
+ ast.Name(id=schema.model_name, ctx=ast.Load()),
1099
+ ],
1100
+ keywords=[],
1101
+ )
1102
+
1103
+ pydantic_block.append(
1104
+ ast.Assign(
1105
+ targets=[
1106
+ ast.Tuple(
1107
+ elts=[
1108
+ ast.Name(id="model_instance", ctx=ast.Store()),
1109
+ ast.Name(id="pydantic_errors", ctx=ast.Store()),
1110
+ ],
1111
+ ctx=ast.Store(),
1112
+ )
1113
+ ],
1114
+ value=validate_call,
1115
+ )
1116
+ )
1117
+
1118
+ # if pydantic_errors: self.errors.update(pydantic_errors)
1119
+ # else: cleaned_data = model_instance
1120
+ pydantic_block.append(
1121
+ ast.If(
1122
+ test=ast.Name(id="pydantic_errors", ctx=ast.Load()),
1123
+ body=[
1124
+ ast.Expr(
1125
+ value=ast.Call(
1126
+ func=ast.Attribute(
1127
+ value=ast.Attribute(
1128
+ value=ast.Name(id="self", ctx=ast.Load()),
1129
+ attr="errors",
1130
+ ctx=ast.Load(),
1131
+ ),
1132
+ attr="update",
1133
+ ctx=ast.Load(),
1134
+ ),
1135
+ args=[ast.Name(id="pydantic_errors", ctx=ast.Load())],
1136
+ keywords=[],
1137
+ )
1138
+ )
1139
+ ],
1140
+ orelse=[
1141
+ ast.Assign(
1142
+ targets=[ast.Name(id="cleaned_data", ctx=ast.Store())],
1143
+ value=ast.Name(id="model_instance", ctx=ast.Load()),
1144
+ )
1145
+ ],
1146
+ )
1147
+ )
1148
+
1149
+ # Wrap in check: if not self.errors:
1150
+ body.append(
1151
+ ast.If(
1152
+ test=ast.UnaryOp(
1153
+ op=ast.Not(),
1154
+ operand=ast.Attribute(
1155
+ value=ast.Name(id="self", ctx=ast.Load()),
1156
+ attr="errors",
1157
+ ctx=ast.Load(),
1158
+ ),
1159
+ ),
1160
+ body=pydantic_block,
1161
+ orelse=[],
1162
+ )
1163
+ )
1164
+
1165
+ # if self.errors: return
1166
+ body.append(
1167
+ ast.If(
1168
+ test=ast.Attribute(
1169
+ value=ast.Name(id="self", ctx=ast.Load()),
1170
+ attr="errors",
1171
+ ctx=ast.Load(),
1172
+ ),
1173
+ body=[ast.Return(value=None)],
1174
+ orelse=[],
1175
+ )
1176
+ )
1177
+
1178
+ # Call original handler - need to check if it's async
1179
+ handler_call = ast.Call(
1180
+ func=ast.Attribute(
1181
+ value=ast.Name(id="self", ctx=ast.Load()),
1182
+ attr=original_handler,
1183
+ ctx=ast.Load(),
1184
+ ),
1185
+ args=[ast.Name(id="cleaned_data", ctx=ast.Load())],
1186
+ keywords=[],
1187
+ )
1188
+
1189
+ # Assume async for safety - await it
1190
+ body.append(ast.Expr(value=ast.Await(value=handler_call)))
1191
+
1192
+ return ast.AsyncFunctionDef(
1193
+ name=wrapper_name,
1194
+ args=ast.arguments(
1195
+ posonlyargs=[],
1196
+ args=[ast.arg(arg="self")],
1197
+ vararg=None,
1198
+ kwonlyargs=[],
1199
+ kw_defaults=[],
1200
+ kwarg=ast.arg(arg="kwargs"),
1201
+ defaults=[],
1202
+ ),
1203
+ body=body,
1204
+ decorator_list=[],
1205
+ returns=None,
1206
+ )
1207
+
1208
+ def _generate_spa_metadata(self, parsed: ParsedPyWire) -> List[ast.stmt]:
1209
+ """Generate __spa_enabled__ and __sibling_paths__ class attributes."""
1210
+ stmts: List[ast.stmt] = []
1211
+
1212
+ # Get path directive
1213
+ path_directive = cast(
1214
+ Optional[PathDirective], parsed.get_directive_by_type(PathDirective)
1215
+ )
1216
+ if path_directive:
1217
+ # assert isinstance(path_directive, PathDirective)
1218
+ pass
1219
+ is_multi_path = path_directive and not path_directive.is_simple_string
1220
+
1221
+ # Check for !no_spa directive
1222
+ no_spa = parsed.get_directive_by_type(NoSpaDirective) is not None
1223
+
1224
+ # SPA is enabled for multi-path pages unless !no_spa is present
1225
+ spa_enabled = is_multi_path and not no_spa
1226
+
1227
+ # __spa_enabled__ = True/False
1228
+ stmts.append(
1229
+ ast.Assign(
1230
+ targets=[ast.Name(id="__spa_enabled__", ctx=ast.Store())],
1231
+ value=ast.Constant(value=bool(spa_enabled)),
1232
+ )
1233
+ )
1234
+
1235
+ # __sibling_paths__ = ['/path1', '/path2', ...]
1236
+ if path_directive and not path_directive.is_simple_string:
1237
+ paths = list(path_directive.routes.values())
1238
+ else:
1239
+ paths = []
1240
+
1241
+ stmts.append(
1242
+ ast.Assign(
1243
+ targets=[ast.Name(id="__sibling_paths__", ctx=ast.Store())],
1244
+ value=ast.List(
1245
+ elts=[ast.Constant(value=p) for p in paths], ctx=ast.Load()
1246
+ ),
1247
+ )
1248
+ )
1249
+
1250
+ # Inject __file_path__ for hot reload route cleanup
1251
+ if parsed.file_path:
1252
+ stmts.append(
1253
+ ast.Assign(
1254
+ targets=[ast.Name(id="__file_path__", ctx=ast.Store())],
1255
+ value=ast.Constant(value=str(parsed.file_path)),
1256
+ )
1257
+ )
1258
+
1259
+ return stmts
1260
+
1261
+ def _get_class_name(self, parsed: ParsedPyWire) -> str:
1262
+ """Generate class name from file path."""
1263
+ if not parsed.file_path:
1264
+ return "Page"
1265
+
1266
+ from pathlib import Path
1267
+
1268
+ path = Path(parsed.file_path)
1269
+ # Convert pages/index.pywire -> IndexPage
1270
+ name = path.stem
1271
+ return "".join(word.capitalize() for word in name.split("_")) + "Page"
1272
+
1273
+ def _generate_init_method(self, parsed: ParsedPyWire) -> ast.FunctionDef:
1274
+ """Generate __init__ method."""
1275
+ # Base init args
1276
+ init_args = [
1277
+ ast.arg(arg="self"),
1278
+ ast.arg(arg="request"),
1279
+ ast.arg(arg="params"),
1280
+ ast.arg(arg="query"),
1281
+ ast.arg(arg="path"),
1282
+ ast.arg(arg="url"),
1283
+ ]
1284
+ defaults: List[ast.expr] = [ast.Constant(value=None), ast.Constant(value=None)]
1285
+ props_assigns: List[ast.stmt] = []
1286
+
1287
+ # Handle Props directive
1288
+ props_directive = parsed.get_directive_by_type(PropsDirective)
1289
+ if props_directive:
1290
+ assert isinstance(props_directive, PropsDirective)
1291
+ # !props(name: type, arg=default)
1292
+ # Add to init_args
1293
+ for name, type_hint, default_val in props_directive.args:
1294
+ # Annotation
1295
+ annotation = (
1296
+ ast.parse(type_hint, mode="eval").body if type_hint else None
1297
+ )
1298
+
1299
+ # Default
1300
+ if default_val is not None:
1301
+ # Parse default value expr
1302
+ defaults.append(ast.parse(default_val, mode="eval").body)
1303
+ else:
1304
+ # No default: must come before args with defaults?
1305
+ # Standard python rules: non-default args first.
1306
+ # But we are appending AFTER request/params etc which DON'T have defaults
1307
+ # (except path/url which do)
1308
+ # Wait, request, params, query don't have defaults in base method signature
1309
+ # we generated before:
1310
+ # args=[arg('self'), arg('request')...]
1311
+ # defaults=[None, None] (for path/url?)
1312
+
1313
+ # Actually standard signature above was:
1314
+ # args: self, request, params, query, path, url
1315
+ # defaults: path=None, url=None
1316
+
1317
+ # So if we add a non-default prop after 'url=None', it's invalid syntax.
1318
+ # "non-default argument follows default argument"
1319
+
1320
+ # Strategy: Make ALL props keyword-only or ensure order?
1321
+ # Components are instantiated with kwargs mostly?
1322
+ # Or we just add them to the end and expect users to provide defaults if we
1323
+ # have defaults before?
1324
+ # Simpler: Make them keyword arguments (kwonlyargs).
1325
+ pass
1326
+
1327
+ # Implementation: Use kwonlyargs for props to avoid mess with positional defaults
1328
+ kwonlyargs: List[ast.arg] = []
1329
+ kw_defaults: List[Optional[ast.expr]] = []
1330
+
1331
+ for name, type_hint, default_val in props_directive.args:
1332
+ annotation = (
1333
+ ast.parse(type_hint, mode="eval").body if type_hint else None
1334
+ )
1335
+ kwonlyargs.append(ast.arg(arg=name, annotation=annotation))
1336
+
1337
+ if default_val is not None:
1338
+ kw_defaults.append(ast.parse(default_val, mode="eval").body)
1339
+ else:
1340
+ kw_defaults.append(None) # Required kwarg
1341
+
1342
+ # Assign to self
1343
+ # self.name = name
1344
+ props_assigns.append(
1345
+ ast.Assign(
1346
+ targets=[
1347
+ ast.Attribute(
1348
+ value=ast.Name(id="self", ctx=ast.Load()),
1349
+ attr=name,
1350
+ ctx=ast.Store(),
1351
+ )
1352
+ ],
1353
+ value=ast.Name(id=name, ctx=ast.Load()),
1354
+ )
1355
+ )
1356
+
1357
+ else:
1358
+ kwonlyargs = []
1359
+ kw_defaults = []
1360
+
1361
+ body: List[ast.stmt] = [
1362
+ ast.Expr(
1363
+ value=ast.Call(
1364
+ func=ast.Attribute(
1365
+ value=ast.Call(
1366
+ func=ast.Name(id="super", ctx=ast.Load()),
1367
+ args=[],
1368
+ keywords=[],
1369
+ ),
1370
+ attr="__init__",
1371
+ ctx=ast.Load(),
1372
+ ),
1373
+ args=[
1374
+ ast.Name(id="request", ctx=ast.Load()),
1375
+ ast.Name(id="params", ctx=ast.Load()),
1376
+ ast.Name(id="query", ctx=ast.Load()),
1377
+ ast.Name(id="path", ctx=ast.Load()),
1378
+ ast.Name(id="url", ctx=ast.Load()),
1379
+ ],
1380
+ keywords=[
1381
+ ast.keyword(
1382
+ arg=None, value=ast.Name(id="kwargs", ctx=ast.Load())
1383
+ )
1384
+ ],
1385
+ )
1386
+ )
1387
+ ]
1388
+
1389
+ # Add prop assignments
1390
+ body.extend(props_assigns)
1391
+
1392
+ # NOTE: !provide is now handled in _generate_render_template_method to ensure reactivity
1393
+
1394
+ # Handle !inject - retrieve values from context
1395
+ inject_directive = parsed.get_directive_by_type(InjectDirective)
1396
+ if inject_directive:
1397
+ assert isinstance(inject_directive, InjectDirective)
1398
+ # self.local_var = self.context.get('GLOBAL_KEY')
1399
+ for local_var, global_key in inject_directive.mapping.items():
1400
+ body.append(
1401
+ ast.Assign(
1402
+ targets=[
1403
+ ast.Attribute(
1404
+ value=ast.Name(id="self", ctx=ast.Load()),
1405
+ attr=local_var,
1406
+ ctx=ast.Store(),
1407
+ )
1408
+ ],
1409
+ value=ast.Call(
1410
+ func=ast.Attribute(
1411
+ value=ast.Attribute(
1412
+ value=ast.Name(id="self", ctx=ast.Load()),
1413
+ attr="context",
1414
+ ctx=ast.Load(),
1415
+ ),
1416
+ attr="get",
1417
+ ctx=ast.Load(),
1418
+ ),
1419
+ args=[ast.Constant(value=global_key)],
1420
+ keywords=[],
1421
+ ),
1422
+ )
1423
+ )
1424
+
1425
+ # Call _init_slots
1426
+ body.append(
1427
+ ast.Expr(
1428
+ value=ast.Call(
1429
+ func=ast.Attribute(
1430
+ value=ast.Name(id="self", ctx=ast.Load()),
1431
+ attr="_init_slots",
1432
+ ctx=ast.Load(),
1433
+ ),
1434
+ args=[],
1435
+ keywords=[],
1436
+ )
1437
+ )
1438
+ )
1439
+
1440
+ # Call __top_level_init__ if it exists (for wire() and mutable init)
1441
+ if hasattr(self, "_has_top_level_init") and self._has_top_level_init:
1442
+ body.append(
1443
+ ast.Expr(
1444
+ value=ast.Call(
1445
+ func=ast.Attribute(
1446
+ value=ast.Name(id="self", ctx=ast.Load()),
1447
+ attr="__top_level_init__",
1448
+ ctx=ast.Load(),
1449
+ ),
1450
+ args=[],
1451
+ keywords=[],
1452
+ )
1453
+ )
1454
+ )
1455
+
1456
+ return ast.FunctionDef(
1457
+ name="__init__",
1458
+ args=ast.arguments(
1459
+ posonlyargs=[],
1460
+ args=init_args,
1461
+ vararg=None,
1462
+ kwonlyargs=kwonlyargs,
1463
+ kw_defaults=kw_defaults,
1464
+ kwarg=ast.arg(arg="kwargs", annotation=None),
1465
+ defaults=defaults,
1466
+ ),
1467
+ body=body,
1468
+ decorator_list=[],
1469
+ returns=None,
1470
+ )
1471
+
1472
+ def _transform_user_code(
1473
+ self, python_ast: ast.Module, known_globals: Optional[Set[str]] = None
1474
+ ) -> List[ast.stmt]:
1475
+ """Transform user Python code to class methods/attributes."""
1476
+ transformed: List[ast.stmt] = []
1477
+ if known_globals is None:
1478
+ known_globals = set()
1479
+
1480
+ # Collect hooks
1481
+ self._collected_mount_hooks = []
1482
+ self._has_top_level_init = False
1483
+
1484
+ top_level_statements: List[ast.stmt] = []
1485
+
1486
+ for node in python_ast.body:
1487
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
1488
+ # Skip imports - already handled
1489
+ continue
1490
+ elif isinstance(node, ast.Assign):
1491
+ # Module-level assignments become class attributes
1492
+ # UNLESS they target 'self' (e.g. self.x = 1), which makes no sense at class level
1493
+ # and implies instance initialization.
1494
+
1495
+ is_instance_assign = False
1496
+ for target in node.targets:
1497
+ # Check if target is Attribute(value=Name(id='self'))
1498
+ if (
1499
+ isinstance(target, ast.Attribute)
1500
+ and isinstance(target.value, ast.Name)
1501
+ and target.value.id == "self"
1502
+ ):
1503
+ is_instance_assign = True
1504
+ break
1505
+
1506
+ # Also check if value is a Call (like wire()) or mutable structure.
1507
+ # These should be instance-level to avoid shared state.
1508
+ is_mutable_init = isinstance(
1509
+ node.value,
1510
+ (
1511
+ ast.Call,
1512
+ ast.List,
1513
+ ast.Dict,
1514
+ ast.Set,
1515
+ ast.ListComp,
1516
+ ast.DictComp,
1517
+ ast.SetComp,
1518
+ ),
1519
+ )
1520
+
1521
+ if is_instance_assign or is_mutable_init:
1522
+ top_level_statements.append(node)
1523
+ else:
1524
+ # Simple literals (int, str) stay class attributes
1525
+ transformed.append(node)
1526
+
1527
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1528
+ # Check for decorators
1529
+ new_decorators = []
1530
+ for dec in node.decorator_list:
1531
+ if isinstance(dec, ast.Name) and dec.id == "mount":
1532
+ self._collected_mount_hooks.append(node.name)
1533
+ elif isinstance(dec, ast.Name) and dec.id == "unmount":
1534
+ # Placeholder for future unmount
1535
+ pass
1536
+ else:
1537
+ new_decorators.append(dec)
1538
+
1539
+ node.decorator_list = new_decorators
1540
+
1541
+ # Functions become methods - transform them
1542
+ transformed.append(self._transform_to_method(node, known_globals))
1543
+ elif isinstance(node, ast.ClassDef):
1544
+ # Classes are moved to module level, skip here
1545
+ continue
1546
+ else:
1547
+ # Other statements (Expr, If, For, While, Try)
1548
+ # Move to top-level init
1549
+ top_level_statements.append(node)
1550
+
1551
+ if top_level_statements:
1552
+ self._has_top_level_init = True
1553
+ transformed.append(
1554
+ self._generate_top_level_init(top_level_statements, known_globals)
1555
+ )
1556
+
1557
+ return transformed
1558
+
1559
+ def _generate_top_level_init(
1560
+ self, statements: List[ast.stmt], known_globals: Set[str]
1561
+ ) -> ast.FunctionDef:
1562
+ """Generate __top_level_init__ method from top-level statements."""
1563
+
1564
+ # 1. Collect all variables assigned in this scope to promote them to instance attributes.
1565
+ # This ensures 'x = 1' inside match/if/for becomes 'self.x = 1'.
1566
+ local_assignments = set()
1567
+
1568
+ class AssignmentCollector(ast.NodeVisitor):
1569
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
1570
+ # Do not recurse into nested functions
1571
+ pass
1572
+
1573
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
1574
+ pass
1575
+
1576
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
1577
+ pass
1578
+
1579
+ def visit_Name(self, node: ast.Name) -> None:
1580
+ # If name is being stored (assigned to), collect it
1581
+ if isinstance(node.ctx, ast.Store):
1582
+ local_assignments.add(node.id)
1583
+
1584
+ collector = AssignmentCollector()
1585
+ for stmt in statements:
1586
+ collector.visit(stmt)
1587
+
1588
+ # Combine with explicit known globals
1589
+ # We start with a copy to avoid mutating the passed set if it's used elsewhere
1590
+ # (though it seems local usually)
1591
+ combined_globals = set(known_globals)
1592
+ combined_globals.update(local_assignments)
1593
+
1594
+ # Wrap statements in sync method (must be sync for __init__)
1595
+ # Transform variables to self.X
1596
+
1597
+ wrapper = ast.FunctionDef(
1598
+ name="__top_level_init__",
1599
+ args=ast.arguments(
1600
+ posonlyargs=[],
1601
+ args=[ast.arg(arg="self")],
1602
+ vararg=None,
1603
+ kwonlyargs=[],
1604
+ kw_defaults=[],
1605
+ defaults=[],
1606
+ ),
1607
+ body=statements,
1608
+ decorator_list=[],
1609
+ returns=None,
1610
+ )
1611
+
1612
+ return cast(
1613
+ ast.FunctionDef, self._transform_to_method(wrapper, combined_globals)
1614
+ )
1615
+
1616
+ def _transform_to_method(
1617
+ self, node: Any, known_methods: Optional[Set[str]] = None
1618
+ ) -> Any:
1619
+ """Transform a function into a method (add self, handle globals)."""
1620
+ # 1. Add self argument if not present
1621
+ if not (node.args.args and node.args.args[0].arg == "self"):
1622
+ node.args.args.insert(0, ast.arg(arg="self"))
1623
+
1624
+ # 2. Find global declarations and include known methods
1625
+ global_vars = set()
1626
+ if known_methods:
1627
+ global_vars.update(known_methods)
1628
+
1629
+ new_body = []
1630
+ for stmt in node.body:
1631
+ if isinstance(stmt, ast.Global):
1632
+ global_vars.update(stmt.names)
1633
+ else:
1634
+ new_body.append(stmt)
1635
+
1636
+ node.body = new_body
1637
+
1638
+ # 3. Transform variable access
1639
+ if global_vars:
1640
+
1641
+ class GlobalToSelf(ast.NodeTransformer):
1642
+ def visit_Name(self, n: ast.Name) -> ast.AST:
1643
+ if n.id in global_vars:
1644
+ return ast.Attribute(
1645
+ value=ast.Name(id="self", ctx=ast.Load()),
1646
+ attr=n.id,
1647
+ ctx=n.ctx,
1648
+ )
1649
+ return n
1650
+
1651
+ # Apply transformation
1652
+ transformer = GlobalToSelf()
1653
+ for i, stmt in enumerate(node.body):
1654
+ node.body[i] = transformer.visit(stmt)
1655
+
1656
+ # Fix locations
1657
+ for stmt in node.body:
1658
+ ast.fix_missing_locations(stmt)
1659
+
1660
+ return node
1661
+
1662
+ def _generate_render_method(self) -> ast.AsyncFunctionDef:
1663
+ """Generate render method."""
1664
+ return ast.AsyncFunctionDef(
1665
+ name="render",
1666
+ args=ast.arguments(
1667
+ posonlyargs=[],
1668
+ args=[ast.arg(arg="self")],
1669
+ vararg=None,
1670
+ kwonlyargs=[],
1671
+ kw_defaults=[],
1672
+ defaults=[],
1673
+ ),
1674
+ body=[
1675
+ ast.Return(
1676
+ value=ast.Call(
1677
+ func=ast.Name(id="Response", ctx=ast.Load()),
1678
+ args=[
1679
+ ast.Await(
1680
+ value=ast.Call(
1681
+ func=ast.Attribute(
1682
+ value=ast.Name(id="self", ctx=ast.Load()),
1683
+ attr="_render_template",
1684
+ ctx=ast.Load(),
1685
+ ),
1686
+ args=[],
1687
+ keywords=[],
1688
+ )
1689
+ )
1690
+ ],
1691
+ keywords=[
1692
+ ast.keyword(
1693
+ arg="media_type", value=ast.Constant(value="text/html")
1694
+ )
1695
+ ],
1696
+ )
1697
+ )
1698
+ ],
1699
+ decorator_list=[],
1700
+ returns=None,
1701
+ )
1702
+
1703
+ def _generate_render_template_method(
1704
+ self,
1705
+ parsed: ParsedPyWire,
1706
+ known_methods: Optional[Set[str]] = None,
1707
+ known_globals: Optional[Set[str]] = None,
1708
+ async_methods: Optional[Set[str]] = None,
1709
+ component_map: Optional[Dict[str, str]] = None,
1710
+ ) -> Tuple[
1711
+ Optional[Union[ast.FunctionDef, ast.AsyncFunctionDef]],
1712
+ List[ast.stmt],
1713
+ ]:
1714
+ """Generate _render_template method and binding/slot handlers."""
1715
+ if component_map is None:
1716
+ component_map = {}
1717
+ # Check for layout
1718
+ layout_directive = parsed.get_directive_by_type(LayoutDirective)
1719
+ if layout_directive:
1720
+ # assert isinstance(layout_directive, LayoutDirective) # Mypy narrowing issue
1721
+ pass
1722
+
1723
+ binding_funcs: List[ast.stmt] = []
1724
+ render_func = None
1725
+
1726
+ if layout_directive:
1727
+ layout_directive = cast(LayoutDirective, layout_directive)
1728
+ # === Layout Mode ===
1729
+ file_id = parsed.file_path or ""
1730
+
1731
+ # Ensure layout_id is generated for intermediate layouts
1732
+ import hashlib
1733
+
1734
+ layout_id = (
1735
+ hashlib.md5(str(parsed.file_path).encode()).hexdigest()
1736
+ if parsed.file_path
1737
+ else None
1738
+ )
1739
+
1740
+ slot_funcs_methods, aux_funcs = self.template_codegen.generate_slot_methods(
1741
+ parsed.template,
1742
+ file_id=file_id,
1743
+ known_globals=known_globals,
1744
+ layout_id=layout_id,
1745
+ component_map=component_map,
1746
+ )
1747
+
1748
+ file_hash = hashlib.md5(file_id.encode()).hexdigest()[:8] if file_id else ""
1749
+
1750
+ # Add slot methods directly (they are ASTs now)
1751
+ for slot_name, func_ast in slot_funcs_methods.items():
1752
+ binding_funcs.append(func_ast)
1753
+
1754
+ # Add aux funcs
1755
+ binding_funcs.extend(aux_funcs)
1756
+
1757
+ # Generate _init_slots
1758
+
1759
+ # Resolve parent layout path
1760
+ from pathlib import Path
1761
+
1762
+ parent_layout_path = layout_directive.layout_path
1763
+ if not Path(parent_layout_path).is_absolute():
1764
+ base_dir = (
1765
+ Path(parsed.file_path).parent if parsed.file_path else Path.cwd()
1766
+ )
1767
+ parent_layout_path = str((base_dir / parent_layout_path).resolve())
1768
+ else:
1769
+ parent_layout_path = str(Path(parent_layout_path).resolve())
1770
+
1771
+ def make_parent_layout_id() -> ast.Constant:
1772
+ import hashlib
1773
+
1774
+ parent_hash = hashlib.md5(parent_layout_path.encode()).hexdigest()
1775
+ return ast.Constant(value=parent_hash)
1776
+
1777
+ init_slots_body: List[ast.stmt] = []
1778
+
1779
+ # Chain super
1780
+ super_check = ast.If(
1781
+ test=ast.Call(
1782
+ func=ast.Name(id="hasattr", ctx=ast.Load()),
1783
+ args=[
1784
+ ast.Call(
1785
+ func=ast.Name(id="super", ctx=ast.Load()),
1786
+ args=[],
1787
+ keywords=[],
1788
+ ),
1789
+ ast.Constant(value="_init_slots"),
1790
+ ],
1791
+ keywords=[],
1792
+ ),
1793
+ body=[
1794
+ ast.Expr(
1795
+ value=ast.Call(
1796
+ func=ast.Attribute(
1797
+ value=ast.Call(
1798
+ func=ast.Name(id="super", ctx=ast.Load()),
1799
+ args=[],
1800
+ keywords=[],
1801
+ ),
1802
+ attr="_init_slots",
1803
+ ctx=ast.Load(),
1804
+ ),
1805
+ args=[],
1806
+ keywords=[],
1807
+ )
1808
+ )
1809
+ ],
1810
+ orelse=[],
1811
+ )
1812
+ init_slots_body.append(super_check)
1813
+
1814
+ for slot_name in slot_funcs_methods.keys():
1815
+ safe_name = (
1816
+ slot_name.replace("$", "_head_").replace("-", "_")
1817
+ if slot_name.startswith("$")
1818
+ else slot_name.replace("-", "_")
1819
+ )
1820
+ func_name = (
1821
+ f"_render_slot_fill_{safe_name}_{file_hash}"
1822
+ if file_hash
1823
+ else f"_render_slot_fill_{safe_name}"
1824
+ )
1825
+
1826
+ if slot_name == "$head":
1827
+ reg_call = ast.Expr(
1828
+ value=ast.Call(
1829
+ func=ast.Attribute(
1830
+ value=ast.Name(id="self", ctx=ast.Load()),
1831
+ attr="register_head_slot",
1832
+ ctx=ast.Load(),
1833
+ ),
1834
+ args=[
1835
+ make_parent_layout_id(),
1836
+ ast.Attribute(
1837
+ value=ast.Name(id="self", ctx=ast.Load()),
1838
+ attr=func_name,
1839
+ ctx=ast.Load(),
1840
+ ),
1841
+ ],
1842
+ keywords=[],
1843
+ )
1844
+ )
1845
+ else:
1846
+ reg_call = ast.Expr(
1847
+ value=ast.Call(
1848
+ func=ast.Attribute(
1849
+ value=ast.Name(id="self", ctx=ast.Load()),
1850
+ attr="register_slot",
1851
+ ctx=ast.Load(),
1852
+ ),
1853
+ args=[
1854
+ make_parent_layout_id(),
1855
+ ast.Constant(value=slot_name),
1856
+ ast.Attribute(
1857
+ value=ast.Name(id="self", ctx=ast.Load()),
1858
+ attr=func_name,
1859
+ ctx=ast.Load(),
1860
+ ),
1861
+ ],
1862
+ keywords=[],
1863
+ )
1864
+ )
1865
+ init_slots_body.append(reg_call)
1866
+
1867
+ init_slots_func = ast.FunctionDef(
1868
+ name="_init_slots",
1869
+ args=ast.arguments(
1870
+ posonlyargs=[],
1871
+ args=[ast.arg(arg="self")],
1872
+ vararg=None,
1873
+ kwonlyargs=[],
1874
+ kw_defaults=[],
1875
+ defaults=[],
1876
+ ),
1877
+ body=init_slots_body,
1878
+ decorator_list=[],
1879
+ returns=None,
1880
+ )
1881
+ binding_funcs.append(init_slots_func)
1882
+
1883
+ # Handle !provide - Override render() to update context before layout rendering
1884
+ provide_directive = cast(
1885
+ Optional[ProvideDirective],
1886
+ parsed.get_directive_by_type(ProvideDirective),
1887
+ )
1888
+ if provide_directive:
1889
+ provide_body: List[ast.stmt] = []
1890
+ for key, val_expr in provide_directive.mapping.items():
1891
+ val_ast = self.template_codegen._transform_expr(
1892
+ val_expr, set(), known_globals
1893
+ )
1894
+ provide_body.append(
1895
+ ast.Assign(
1896
+ targets=[
1897
+ ast.Subscript(
1898
+ value=ast.Attribute(
1899
+ value=ast.Name(id="self", ctx=ast.Load()),
1900
+ attr="context",
1901
+ ctx=ast.Load(),
1902
+ ),
1903
+ slice=ast.Constant(value=key),
1904
+ ctx=ast.Store(),
1905
+ )
1906
+ ],
1907
+ value=val_ast,
1908
+ )
1909
+ )
1910
+
1911
+ # return await super().render(init)
1912
+ render_call = ast.Call(
1913
+ func=ast.Attribute(
1914
+ value=ast.Call(
1915
+ func=ast.Name(id="super", ctx=ast.Load()),
1916
+ args=[],
1917
+ keywords=[],
1918
+ ),
1919
+ attr="render",
1920
+ ctx=ast.Load(),
1921
+ ),
1922
+ args=[ast.Name(id="init", ctx=ast.Load())],
1923
+ keywords=[],
1924
+ )
1925
+ provide_body.append(ast.Return(value=ast.Await(value=render_call)))
1926
+
1927
+ render_override = ast.AsyncFunctionDef(
1928
+ name="render",
1929
+ args=ast.arguments(
1930
+ posonlyargs=[],
1931
+ args=[
1932
+ ast.arg(arg="self"),
1933
+ ast.arg(
1934
+ arg="init",
1935
+ annotation=ast.Name(id="bool", ctx=ast.Load()),
1936
+ ),
1937
+ ],
1938
+ vararg=None,
1939
+ kwonlyargs=[],
1940
+ kw_defaults=[],
1941
+ defaults=[ast.Constant(value=True)],
1942
+ ),
1943
+ body=provide_body,
1944
+ decorator_list=[],
1945
+ returns=None,
1946
+ )
1947
+ binding_funcs.append(render_override)
1948
+
1949
+ else:
1950
+ # === Standard Mode ===
1951
+ # We no longer aggressively generate layout_id/scope_id for everything
1952
+ # to avoid breaking existing tests.
1953
+ layout_id = None
1954
+ scope_id = None
1955
+
1956
+ if parsed.file_path:
1957
+ import hashlib
1958
+
1959
+ layout_id_hash = hashlib.md5(str(parsed.file_path).encode()).hexdigest()
1960
+ # Use as layout_id if we have slots to fill for ourselves (as a component)
1961
+ # Or for scoping if <style scoped> is present
1962
+ has_scoped_style = any(
1963
+ n.tag == "style" and "scoped" in n.attributes
1964
+ for n in parsed.template
1965
+ )
1966
+ if has_scoped_style:
1967
+ scope_id = layout_id_hash[:8]
1968
+
1969
+ # If we are a layout (referenced by others), we should have a LAYOUT_ID.
1970
+ # But we don't know if we ARE a layout here.
1971
+ # We'll assume if there are <slot> tags, we might be a layout.
1972
+ has_slots = self._has_slots_recursive(parsed.template)
1973
+ if has_slots:
1974
+ layout_id = layout_id_hash
1975
+
1976
+ # Extract Props to Unpack
1977
+
1978
+ prop_names = set()
1979
+ props_unpack_stmts = []
1980
+
1981
+ # Using imported PropsDirective from earlier context or get it again
1982
+ # We are inside the method, 'parsed' is available.
1983
+ props_directive = cast(
1984
+ Optional[PropsDirective], parsed.get_directive_by_type(PropsDirective)
1985
+ )
1986
+ if props_directive:
1987
+ for name, _, _ in props_directive.args:
1988
+ prop_names.add(name)
1989
+ # prop = self.prop
1990
+ props_unpack_stmts.append(
1991
+ ast.Assign(
1992
+ targets=[ast.Name(id=name, ctx=ast.Store())],
1993
+ value=ast.Attribute(
1994
+ value=ast.Name(id="self", ctx=ast.Load()),
1995
+ attr=name,
1996
+ ctx=ast.Load(),
1997
+ ),
1998
+ )
1999
+ )
2000
+
2001
+ render_func, aux_funcs = self.template_codegen.generate_render_method(
2002
+ parsed.template,
2003
+ layout_id=layout_id or "",
2004
+ known_methods=known_methods,
2005
+ known_globals=known_globals,
2006
+ async_methods=async_methods,
2007
+ component_map=component_map,
2008
+ scope_id=scope_id,
2009
+ initial_locals=prop_names,
2010
+ )
2011
+
2012
+ # Prepend unpack statements to render_func body
2013
+ if render_func and props_unpack_stmts:
2014
+ render_func.body[0:0] = props_unpack_stmts
2015
+
2016
+ # Handle !provide - Update context values at start of render to catch state changes
2017
+ provide_directive = cast(
2018
+ Optional[ProvideDirective],
2019
+ parsed.get_directive_by_type(ProvideDirective),
2020
+ )
2021
+ if provide_directive and render_func:
2022
+ provide_stmts = []
2023
+ for key, val_expr in provide_directive.mapping.items():
2024
+ # Transform expression using known globals for this page scope
2025
+ # Note: val_expr is string. We need to parse it or use transform helper.
2026
+
2027
+ val_ast = self.template_codegen._transform_expr(
2028
+ val_expr, set(), known_globals
2029
+ )
2030
+
2031
+ provide_stmts.append(
2032
+ ast.Assign(
2033
+ targets=[
2034
+ ast.Subscript(
2035
+ value=ast.Attribute(
2036
+ value=ast.Name(id="self", ctx=ast.Load()),
2037
+ attr="context",
2038
+ ctx=ast.Load(),
2039
+ ),
2040
+ slice=ast.Constant(value=key),
2041
+ ctx=ast.Store(),
2042
+ )
2043
+ ],
2044
+ value=val_ast,
2045
+ )
2046
+ )
2047
+
2048
+ # Insert after props unpacking (if any)
2049
+ insert_idx = len(props_unpack_stmts) if props_unpack_stmts else 0
2050
+ render_func.body[insert_idx:insert_idx] = provide_stmts
2051
+
2052
+ binding_funcs.extend(aux_funcs)
2053
+
2054
+ # SPA injection
2055
+ # (path_directive search removed if unused)
2056
+
2057
+ # Determine injection point (before final return)
2058
+
2059
+ spa_check = ast.If(
2060
+ test=ast.BoolOp(
2061
+ op=ast.And(),
2062
+ values=[
2063
+ ast.UnaryOp(
2064
+ op=ast.Not(),
2065
+ operand=ast.Call(
2066
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2067
+ args=[
2068
+ ast.Name(id="self", ctx=ast.Load()),
2069
+ ast.Constant(value="__no_spa__"),
2070
+ ast.Constant(value=False),
2071
+ ],
2072
+ keywords=[],
2073
+ ),
2074
+ ),
2075
+ ast.UnaryOp(
2076
+ op=ast.Not(),
2077
+ operand=ast.Call(
2078
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2079
+ args=[
2080
+ ast.Name(id="self", ctx=ast.Load()),
2081
+ ast.Constant(value="__is_component__"),
2082
+ ast.Constant(value=False),
2083
+ ],
2084
+ keywords=[],
2085
+ ),
2086
+ ),
2087
+ ast.BoolOp(
2088
+ op=ast.Or(),
2089
+ values=[
2090
+ ast.Call(
2091
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2092
+ args=[
2093
+ ast.Name(id="self", ctx=ast.Load()),
2094
+ ast.Constant(value="__spa_enabled__"),
2095
+ ast.Constant(value=False),
2096
+ ],
2097
+ keywords=[],
2098
+ ),
2099
+ ast.Call(
2100
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2101
+ args=[
2102
+ ast.Attribute(
2103
+ value=ast.Attribute(
2104
+ value=ast.Attribute(
2105
+ value=ast.Name(
2106
+ id="self", ctx=ast.Load()
2107
+ ),
2108
+ attr="request",
2109
+ ctx=ast.Load(),
2110
+ ),
2111
+ attr="app",
2112
+ ctx=ast.Load(),
2113
+ ),
2114
+ attr="state",
2115
+ ctx=ast.Load(),
2116
+ ),
2117
+ ast.Constant(value="enable_pjax"),
2118
+ ast.Constant(value=False),
2119
+ ],
2120
+ keywords=[],
2121
+ ),
2122
+ ],
2123
+ ),
2124
+ ],
2125
+ ),
2126
+ body=[
2127
+ # sibling_paths = ...
2128
+ ast.Assign(
2129
+ targets=[ast.Name(id="sibling_paths", ctx=ast.Store())],
2130
+ value=ast.Call(
2131
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2132
+ args=[
2133
+ ast.Name(id="self", ctx=ast.Load()),
2134
+ ast.Constant(value="__sibling_paths__"),
2135
+ ast.List(elts=[], ctx=ast.Load()),
2136
+ ],
2137
+ keywords=[],
2138
+ ),
2139
+ ),
2140
+ # pjax_enabled = ...
2141
+ ast.Assign(
2142
+ targets=[ast.Name(id="pjax_enabled", ctx=ast.Store())],
2143
+ value=ast.Call(
2144
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2145
+ args=[
2146
+ ast.Attribute(
2147
+ value=ast.Attribute(
2148
+ value=ast.Attribute(
2149
+ value=ast.Name(id="self", ctx=ast.Load()),
2150
+ attr="request",
2151
+ ctx=ast.Load(),
2152
+ ),
2153
+ attr="app",
2154
+ ctx=ast.Load(),
2155
+ ),
2156
+ attr="state",
2157
+ ctx=ast.Load(),
2158
+ ),
2159
+ ast.Constant(value="enable_pjax"),
2160
+ ast.Constant(value=False),
2161
+ ],
2162
+ keywords=[],
2163
+ ),
2164
+ ),
2165
+ # debug = ...
2166
+ ast.Assign(
2167
+ targets=[ast.Name(id="debug", ctx=ast.Store())],
2168
+ value=ast.Call(
2169
+ func=ast.Name(id="getattr", ctx=ast.Load()),
2170
+ args=[
2171
+ ast.Attribute(
2172
+ value=ast.Attribute(
2173
+ value=ast.Attribute(
2174
+ value=ast.Name(id="self", ctx=ast.Load()),
2175
+ attr="request",
2176
+ ctx=ast.Load(),
2177
+ ),
2178
+ attr="app",
2179
+ ctx=ast.Load(),
2180
+ ),
2181
+ attr="state",
2182
+ ctx=ast.Load(),
2183
+ ),
2184
+ ast.Constant(value="debug"),
2185
+ ast.Constant(value=False),
2186
+ ],
2187
+ keywords=[],
2188
+ ),
2189
+ ),
2190
+ # parts.append(script tag)
2191
+ ast.Expr(
2192
+ value=ast.Call(
2193
+ func=ast.Attribute(
2194
+ value=ast.Name(id="parts", ctx=ast.Load()),
2195
+ attr="append",
2196
+ ctx=ast.Load(),
2197
+ ),
2198
+ args=[
2199
+ ast.Constant(
2200
+ value='<script id="_pywire_spa_meta" type="application/json">'
2201
+ )
2202
+ ],
2203
+ keywords=[],
2204
+ )
2205
+ ),
2206
+ # parts.append(json.dumps(...))
2207
+ ast.Expr(
2208
+ value=ast.Call(
2209
+ func=ast.Attribute(
2210
+ value=ast.Name(id="parts", ctx=ast.Load()),
2211
+ attr="append",
2212
+ ctx=ast.Load(),
2213
+ ),
2214
+ args=[
2215
+ ast.Call(
2216
+ func=ast.Attribute(
2217
+ value=ast.Name(id="json", ctx=ast.Load()),
2218
+ attr="dumps",
2219
+ ctx=ast.Load(),
2220
+ ),
2221
+ args=[
2222
+ ast.Dict(
2223
+ keys=[
2224
+ ast.Constant(value="sibling_paths"),
2225
+ ast.Constant(value="enable_pjax"),
2226
+ ast.Constant(value="debug"),
2227
+ ],
2228
+ values=[
2229
+ ast.Name(
2230
+ id="sibling_paths", ctx=ast.Load()
2231
+ ),
2232
+ ast.Name(
2233
+ id="pjax_enabled", ctx=ast.Load()
2234
+ ),
2235
+ ast.Name(id="debug", ctx=ast.Load()),
2236
+ ],
2237
+ )
2238
+ ],
2239
+ keywords=[],
2240
+ )
2241
+ ],
2242
+ keywords=[],
2243
+ )
2244
+ ),
2245
+ ast.Expr(
2246
+ value=ast.Call(
2247
+ func=ast.Attribute(
2248
+ value=ast.Name(id="parts", ctx=ast.Load()),
2249
+ attr="append",
2250
+ ctx=ast.Load(),
2251
+ ),
2252
+ args=[ast.Constant(value="</script>")],
2253
+ keywords=[],
2254
+ )
2255
+ ),
2256
+ ast.Expr(
2257
+ value=ast.Call(
2258
+ func=ast.Attribute(
2259
+ value=ast.Name(id="parts", ctx=ast.Load()),
2260
+ attr="append",
2261
+ ctx=ast.Load(),
2262
+ ),
2263
+ args=[
2264
+ ast.JoinedStr(
2265
+ values=[
2266
+ ast.Constant(value='<script src="'),
2267
+ ast.FormattedValue(
2268
+ value=ast.Call(
2269
+ func=cast(
2270
+ ast.Expr,
2271
+ ast.parse(
2272
+ "self.request.app.state.pywire._get_client_script_url"
2273
+ ).body[0],
2274
+ ).value,
2275
+ args=[],
2276
+ keywords=[],
2277
+ ),
2278
+ conversion=-1,
2279
+ format_spec=None,
2280
+ ),
2281
+ ast.Constant(value='"></script>'),
2282
+ ]
2283
+ )
2284
+ ],
2285
+ keywords=[],
2286
+ )
2287
+ ),
2288
+ ],
2289
+ orelse=[],
2290
+ )
2291
+
2292
+ # Insert before last statement
2293
+ if render_func.body and isinstance(render_func.body[-1], ast.Return):
2294
+ render_func.body.insert(-1, spa_check)
2295
+ else:
2296
+ render_func.body.append(spa_check)
2297
+
2298
+ # Add no-op _init_slots
2299
+ binding_funcs.append(
2300
+ ast.FunctionDef(
2301
+ name="_init_slots",
2302
+ args=ast.arguments(
2303
+ posonlyargs=[],
2304
+ args=[ast.arg(arg="self")],
2305
+ vararg=None,
2306
+ kwonlyargs=[],
2307
+ kw_defaults=[],
2308
+ defaults=[],
2309
+ ),
2310
+ body=[ast.Pass()],
2311
+ decorator_list=[],
2312
+ returns=None,
2313
+ )
2314
+ )
2315
+
2316
+ if self.template_codegen.region_renderers:
2317
+ region_keys: List[ast.expr | None] = []
2318
+ region_vals: List[ast.expr] = []
2319
+ for (
2320
+ region_id,
2321
+ method_name,
2322
+ ) in self.template_codegen.region_renderers.items():
2323
+ region_keys.append(ast.Constant(value=region_id))
2324
+ region_vals.append(ast.Constant(value=method_name))
2325
+ binding_funcs.append(
2326
+ ast.Assign(
2327
+ targets=[ast.Name(id="__region_renderers__", ctx=ast.Store())],
2328
+ value=ast.Dict(keys=region_keys, values=region_vals),
2329
+ )
2330
+ )
2331
+
2332
+ return render_func, binding_funcs
2333
+
2334
+ def _has_slots_recursive(self, nodes: List[TemplateNode]) -> bool:
2335
+ """Check recursively if the template contains any <slot> elements."""
2336
+ for node in nodes:
2337
+ if node.tag == "slot":
2338
+ return True
2339
+ if self._has_slots_recursive(node.children):
2340
+ return True
2341
+ return False