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,750 @@
1
+ """Main PyWire parser orchestrator."""
2
+
3
+ import ast
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Tuple, Union
7
+
8
+ from lxml import html # type: ignore
9
+
10
+ from pywire.compiler.ast_nodes import (
11
+ EventAttribute,
12
+ FieldValidationRules,
13
+ FormValidationSchema,
14
+ InterpolationNode,
15
+ ModelAttribute,
16
+ ParsedPyWire,
17
+ ReactiveAttribute,
18
+ SpecialAttribute,
19
+ SpreadAttribute,
20
+ TemplateNode,
21
+ )
22
+ from pywire.compiler.attributes.base import AttributeParser
23
+ from pywire.compiler.attributes.conditional import ConditionalAttributeParser
24
+ from pywire.compiler.attributes.events import EventAttributeParser
25
+ from pywire.compiler.attributes.form import ModelAttributeParser
26
+ from pywire.compiler.attributes.loop import KeyAttributeParser, LoopAttributeParser
27
+ from pywire.compiler.directives.base import DirectiveParser
28
+ from pywire.compiler.directives.component import ComponentDirectiveParser
29
+ from pywire.compiler.directives.context import ContextDirectiveParser
30
+ from pywire.compiler.directives.layout import LayoutDirectiveParser
31
+ from pywire.compiler.directives.no_spa import NoSpaDirectiveParser
32
+ from pywire.compiler.directives.path import PathDirectiveParser
33
+ from pywire.compiler.directives.props import PropsDirectiveParser
34
+ from pywire.compiler.exceptions import PyWireSyntaxError
35
+ from pywire.compiler.interpolation.jinja import JinjaInterpolationParser
36
+
37
+
38
+ class PyWireParser:
39
+ """Main parser orchestrator."""
40
+
41
+ _separator_re = re.compile(r"^\s*(-{3,})\s*html\s*\1\s*$", re.IGNORECASE)
42
+
43
+ def __init__(self) -> None:
44
+ # Directive registry
45
+ self.directive_parsers: List[DirectiveParser] = [
46
+ PathDirectiveParser(),
47
+ NoSpaDirectiveParser(),
48
+ LayoutDirectiveParser(),
49
+ ComponentDirectiveParser(),
50
+ PropsDirectiveParser(),
51
+ ContextDirectiveParser(),
52
+ ]
53
+
54
+ # Attribute parser chain
55
+ self.attribute_parsers: List[AttributeParser] = [
56
+ EventAttributeParser(),
57
+ ConditionalAttributeParser(),
58
+ LoopAttributeParser(),
59
+ KeyAttributeParser(),
60
+ ModelAttributeParser(),
61
+ ]
62
+
63
+ # Interpolation parser (pluggable)
64
+ self.interpolation_parser = JinjaInterpolationParser()
65
+
66
+ def parse_file(self, file_path: Path) -> ParsedPyWire:
67
+ """Parse a .pywire file."""
68
+ with open(file_path, "r", encoding="utf-8") as f:
69
+ content = f.read()
70
+
71
+ return self.parse(content, str(file_path))
72
+
73
+ def parse(self, content: str, file_path: str = "") -> ParsedPyWire:
74
+ """Parse PyWire content."""
75
+ lines = content.split("\n")
76
+ separator_index = self._find_separator_line(lines, file_path)
77
+
78
+ python_section = ""
79
+ python_start_line = -1
80
+ template_lines: List[str] = []
81
+
82
+ if separator_index is not None:
83
+ header_lines = lines[:separator_index]
84
+
85
+ # Parse directives at the top of the header, remaining lines are Python
86
+ directives, python_lines, python_start_line = self._parse_header_sections(
87
+ header_lines
88
+ )
89
+ python_section = "\n".join(python_lines)
90
+
91
+ # HTML comes after the separator; pad to preserve line numbers
92
+ template_lines = [""] * (separator_index + 1) + lines[separator_index + 1 :]
93
+ else:
94
+ # No separator - validate that there's no malformed separator or orphaned Python code
95
+ self._validate_no_orphaned_python(lines, file_path)
96
+ directives, template_lines = self._parse_directives_and_template(lines)
97
+
98
+ # Parse directives/template sections already handled above
99
+
100
+ # Parse template HTML using lxml
101
+ template_html = "\n".join(template_lines)
102
+ template_nodes = []
103
+
104
+ if template_html.strip():
105
+ # Pre-process: Replace <head> with <pywire-head> to preserve it
106
+ # lxml strips standalone <head> tags in fragment mode
107
+ import re
108
+
109
+ template_html = re.sub(
110
+ r"<head(\s|>|/>)", r"<pywire-head\1", template_html, flags=re.IGNORECASE
111
+ )
112
+ template_html = re.sub(
113
+ r"</head>", r"</pywire-head>", template_html, flags=re.IGNORECASE
114
+ )
115
+
116
+ # Pre-process: Handle unquoted attribute values with braces (Svelte/React style)
117
+ # Regex: attr={value} -> attr="{value}"
118
+ # This allows lxml to parse attributes containing spaces (e.g. @click={count += 1})
119
+ # Limitation: Does not handle nested braces for now.
120
+ def quote_wrapper(match: re.Match[str]) -> str:
121
+ attr = match.group(1)
122
+ value = match.group(2)
123
+ # If value contains double quotes, wrap in single quotes
124
+ if '"' in value:
125
+ return f"{attr}='{{{value}}}'"
126
+ return f'{attr}="{{{value}}}"'
127
+
128
+ template_html = re.sub(
129
+ r"([a-zA-Z0-9_:@$-]+)=\{([^{}]*)\}", quote_wrapper, template_html
130
+ )
131
+
132
+ # Pre-process: Handle {**spread} syntax
133
+ # Convert {**...} to __pywire_spread__="{**...}" so lxml can parse it
134
+ # Regex: look for {** followed by anything until } preceded by
135
+ # whitespace or start of string
136
+ # Be careful not to match inside string literals or text content if avoidable.
137
+ # Simple heuristic: Only match if it looks like an attribute (preceded by space)
138
+ # and strictly follows {** pattern.
139
+ template_html = re.sub(
140
+ r'(?<=[\s"\'])(\{\*\*.*?\})', r'__pywire_spread__="\1"', template_html
141
+ )
142
+
143
+ # lxml.html.fragments_fromstring handles multiple top-level elements
144
+ # It returns a list of elements and strings (for top-level text)
145
+ try:
146
+ # fragments_fromstring might raise error if html is empty or very partial
147
+ # Check for full document to preserve head/body
148
+ clean_html = template_html.strip().lower()
149
+ if clean_html.startswith("<!doctype") or clean_html.startswith("<html"):
150
+ root = html.fromstring(template_html)
151
+ fragments = [root]
152
+ else:
153
+ fragments = html.fragments_fromstring(template_html)
154
+
155
+ for frag in fragments:
156
+ if isinstance(frag, str):
157
+ # Top-level text
158
+ # Approximation: assume it starts at line 1 if first, or...
159
+ # lxml doesn't give line specific info for string fragments.
160
+ # We'll use 0 or try to track line count (hard without full context).
161
+ text_nodes = self._parse_text(frag, start_line=0)
162
+ if text_nodes:
163
+ template_nodes.extend(text_nodes)
164
+ else:
165
+ # Element
166
+ mapped_node = self._map_node(frag)
167
+ template_nodes.append(mapped_node)
168
+
169
+ # Handle tail text of top-level element (text after it)
170
+ # Wait, lxml fragments_fromstring returns mixed list of elements and strings
171
+ # so tail text is usually returned as a subsequent string fragment.
172
+ # BUT, documentation says: "Returns a list of the elements found..."
173
+ # It doesn't always guarantee correct tail handling for top level.
174
+ # Let's verify:
175
+ # fragments_fromstring("<div></div>text") -> [Element div, "text"]
176
+ # elements tail is probably not set if it's top level list??
177
+ # Actually if we use fragments_fromstring, checking tail is safe.
178
+
179
+ if frag.tail:
180
+ # Wait, if fragments_fromstring returns it as separate string
181
+ # item, we duplicate?
182
+ # Let's rely on testing. If lxml puts it in list,
183
+ # frag.tail should be None?
184
+ # Nope, lxml behavior:
185
+ # fragments_fromstring("<a></a>tail") -> [Element a]
186
+ # The tail is attached to 'a'.
187
+ # So we DO need to handle tail here.
188
+
189
+ # Tail starts after element processing.
190
+ # Simple approximation: uses element.sourceline.
191
+ # For better accuracy we'd count lines in element+children.
192
+ tail_nodes = self._parse_text(
193
+ frag.tail, start_line=getattr(frag, "sourceline", 0)
194
+ )
195
+ if tail_nodes:
196
+ template_nodes.extend(tail_nodes)
197
+
198
+ except PyWireSyntaxError:
199
+ raise
200
+ except Exception:
201
+ # Failed to parse, maybe empty or purely comment?
202
+ # or critical error
203
+ import traceback
204
+
205
+ traceback.print_exc()
206
+ pass
207
+
208
+ # Parse Python code
209
+ python_ast = None
210
+ if python_section.strip():
211
+ try:
212
+ # Don't silence SyntaxError - let it bubble up so user knows their code is invalid
213
+ from pywire.compiler.preprocessor import preprocess_python_code
214
+
215
+ preprocessed_code = preprocess_python_code(python_section)
216
+ python_ast = ast.parse(preprocessed_code)
217
+ except SyntaxError as e:
218
+ # Calculate correct line number
219
+ # python_start_line is 0-indexed line number of first python line
220
+ # e.lineno is 1-indexed relative to python_section
221
+ actual_line = python_start_line + (e.lineno or 1)
222
+
223
+ raise PyWireSyntaxError(
224
+ f"Python syntax error: {e.msg}",
225
+ file_path=file_path,
226
+ line=actual_line,
227
+ )
228
+
229
+ if python_ast and python_start_line >= 0:
230
+ # Shift line numbers to match original file
231
+ # python_start_line is index of first python line
232
+ # Current AST lines start at 1.
233
+ # We want line 1 to map to python_start_line + 1
234
+ ast.increment_lineno(python_ast, python_start_line)
235
+
236
+ return ParsedPyWire(
237
+ directives=directives,
238
+ template=template_nodes,
239
+ python_code=python_section,
240
+ python_ast=python_ast,
241
+ file_path=file_path,
242
+ )
243
+
244
+ def _find_separator_line(
245
+ self, lines: List[str], file_path: str
246
+ ) -> Union[int, None]:
247
+ separator_indices = []
248
+
249
+ for i, line in enumerate(lines):
250
+ stripped = line.strip()
251
+ if not stripped:
252
+ continue
253
+
254
+ if self._separator_re.match(stripped):
255
+ separator_indices.append(i)
256
+ continue
257
+
258
+ if self._looks_like_separator_line(stripped):
259
+ raise PyWireSyntaxError(
260
+ f"Malformed separator on line {i + 1}: '{stripped}'. "
261
+ "Expected symmetric dashes around 'html', e.g. '---html---'.",
262
+ file_path=file_path,
263
+ line=i + 1,
264
+ )
265
+
266
+ if len(separator_indices) > 1:
267
+ raise PyWireSyntaxError(
268
+ "Multiple HTML separators found. Only one '---html---' line is allowed.",
269
+ file_path=file_path,
270
+ line=separator_indices[1] + 1,
271
+ )
272
+
273
+ return separator_indices[0] if separator_indices else None
274
+
275
+ def _looks_like_separator_line(self, stripped: str) -> bool:
276
+ if not stripped:
277
+ return False
278
+ if "html" in stripped.lower() and "-" in stripped:
279
+ return True
280
+ if all(c == "-" for c in stripped) and len(stripped) >= 3:
281
+ return True
282
+ return False
283
+
284
+ def _parse_header_sections(
285
+ self, header_lines: List[str]
286
+ ) -> Tuple[List[Any], List[str], int]:
287
+ directives: List[Any] = []
288
+ python_lines: List[str] = []
289
+ pending_blanks: List[str] = []
290
+ python_start_line = -1
291
+
292
+ i = 0
293
+ while i < len(header_lines):
294
+ line = header_lines[i]
295
+ line_stripped = line.strip()
296
+ line_num = i + 1
297
+
298
+ if not line_stripped:
299
+ pending_blanks.append(line)
300
+ i += 1
301
+ continue
302
+
303
+ found_directive = False
304
+ for parser in self.directive_parsers:
305
+ if parser.can_parse(line_stripped):
306
+ directive = parser.parse(line_stripped, line_num, 0)
307
+ if directive:
308
+ directives.append(directive)
309
+ found_directive = True
310
+ pending_blanks = []
311
+ i += 1
312
+ break
313
+
314
+ accumulated = line_stripped
315
+ brace_count = accumulated.count("{") - accumulated.count("}")
316
+ bracket_count = accumulated.count("[") - accumulated.count("]")
317
+ paren_count = accumulated.count("(") - accumulated.count(")")
318
+
319
+ j = i + 1
320
+ while (
321
+ brace_count > 0 or bracket_count > 0 or paren_count > 0
322
+ ) and j < len(header_lines):
323
+ next_line = header_lines[j].strip()
324
+ accumulated += "\n" + next_line
325
+ brace_count += next_line.count("{") - next_line.count("}")
326
+ bracket_count += next_line.count("[") - next_line.count("]")
327
+ paren_count += next_line.count("(") - next_line.count(")")
328
+ j += 1
329
+
330
+ directive = parser.parse(accumulated, line_num, 0)
331
+ if directive:
332
+ directives.append(directive)
333
+ found_directive = True
334
+ pending_blanks = []
335
+ i = j
336
+ break
337
+ break
338
+
339
+ if found_directive:
340
+ continue
341
+
342
+ python_start_line = i - len(pending_blanks)
343
+ python_lines.extend(pending_blanks)
344
+ python_lines.extend(header_lines[i:])
345
+ pending_blanks = []
346
+ break
347
+
348
+ return directives, python_lines, python_start_line
349
+
350
+ def _parse_directives_and_template(
351
+ self, all_lines: List[str]
352
+ ) -> Tuple[List[Any], List[str]]:
353
+ directives: List[Any] = []
354
+ template_lines: List[str] = []
355
+ directives_done = False
356
+
357
+ i = 0
358
+ while i < len(all_lines):
359
+ old_i = i
360
+ line = all_lines[i]
361
+ line_stripped = line.strip()
362
+ line_num = i + 1
363
+
364
+ if not line_stripped:
365
+ if directives_done:
366
+ template_lines.append(line)
367
+ i += 1
368
+ continue
369
+
370
+ found_directive = False
371
+ if not directives_done:
372
+ for parser in self.directive_parsers:
373
+ if parser.can_parse(line_stripped):
374
+ directive = parser.parse(line_stripped, line_num, 0)
375
+ if directive:
376
+ directives.append(directive)
377
+ found_directive = True
378
+ i += 1
379
+ break
380
+
381
+ accumulated = line_stripped
382
+ brace_count = accumulated.count("{") - accumulated.count("}")
383
+ bracket_count = accumulated.count("[") - accumulated.count("]")
384
+ paren_count = accumulated.count("(") - accumulated.count(")")
385
+
386
+ j = i + 1
387
+
388
+ while (
389
+ brace_count > 0 or bracket_count > 0 or paren_count > 0
390
+ ) and j < len(all_lines):
391
+ next_line = all_lines[j].strip()
392
+ accumulated += "\n" + next_line
393
+ brace_count += next_line.count("{") - next_line.count("}")
394
+ bracket_count += next_line.count("[") - next_line.count("]")
395
+ paren_count += next_line.count("(") - next_line.count(")")
396
+ j += 1
397
+
398
+ directive = parser.parse(accumulated, line_num, 0)
399
+ if directive:
400
+ directives.append(directive)
401
+ found_directive = True
402
+ i = j
403
+ break
404
+ i += 1
405
+ break
406
+
407
+ if found_directive:
408
+ for _ in range(i - old_i):
409
+ template_lines.append("")
410
+ else:
411
+ directives_done = True
412
+ template_lines.append(line)
413
+ i += 1
414
+
415
+ return directives, template_lines
416
+
417
+ def _parse_text(
418
+ self, text: str, start_line: int = 0, raw_text: bool = False
419
+ ) -> List[TemplateNode]:
420
+ """Helper to parse text string into list of text/interpolation nodes."""
421
+ if not text:
422
+ return []
423
+
424
+ if raw_text:
425
+ # Bypass interpolation for raw text elements (script, style)
426
+ return [
427
+ TemplateNode(
428
+ tag=None, text_content=text, line=start_line, column=0, is_raw=True
429
+ )
430
+ ]
431
+
432
+ parts = self.interpolation_parser.parse(text, line=start_line, col=0)
433
+ nodes = []
434
+ for part in parts:
435
+ if isinstance(part, str):
436
+ if parts: # Keep whitespace unless explicitly stripping policy?
437
+ # Current policy seems to be keep unless empty?
438
+ # "if not text.strip(): return" was in old parser
439
+ # But if we are inside <pre>, we need it.
440
+ # BS4/lxml default to preserving.
441
+ nodes.append(
442
+ TemplateNode(
443
+ tag=None, text_content=part, line=start_line, column=0
444
+ )
445
+ )
446
+ else:
447
+ node = TemplateNode(
448
+ tag=None, text_content=None, line=part.line, column=part.column
449
+ )
450
+ node.special_attributes = [part]
451
+ nodes.append(node)
452
+ return nodes
453
+
454
+ def _map_node(self, element: html.HtmlElement) -> TemplateNode:
455
+ # lxml elements have tag, attrib, text, tail
456
+
457
+ # Parse attributes
458
+ regular_attrs, special_attrs = self._parse_attributes(dict(element.attrib))
459
+
460
+ node = TemplateNode(
461
+ tag=element.tag,
462
+ attributes=regular_attrs,
463
+ special_attributes=special_attrs,
464
+ line=getattr(element, "sourceline", 0),
465
+ column=0,
466
+ )
467
+
468
+ # Handle inner text (before first child)
469
+ if element.text:
470
+ is_raw = isinstance(element.tag, str) and element.tag.lower() in (
471
+ "script",
472
+ "style",
473
+ )
474
+ text_nodes = self._parse_text(
475
+ element.text,
476
+ start_line=getattr(element, "sourceline", 0),
477
+ raw_text=is_raw,
478
+ )
479
+ if text_nodes:
480
+ node.children.extend(text_nodes)
481
+
482
+ # Handle children
483
+ for child in element:
484
+ # Special logic: lxml comments are Elements with generic function tag
485
+ if isinstance(child, html.HtmlComment):
486
+ continue # Skip comments
487
+ if not isinstance(child.tag, str):
488
+ # Processing instruction etc
489
+ continue
490
+
491
+ # 1. Map child element
492
+ child_node = self._map_node(child)
493
+ node.children.append(child_node)
494
+
495
+ # 2. Handle child's tail (text immediately after child, before next sibling)
496
+ if child.tail:
497
+ tail_nodes = self._parse_text(
498
+ child.tail, start_line=getattr(child, "sourceline", 0)
499
+ )
500
+ if tail_nodes:
501
+ node.children.extend(tail_nodes)
502
+
503
+ # === Form Validation Schema Extraction ===
504
+ # If this is a <form> with @submit, extract validation rules from child inputs
505
+ if isinstance(element.tag, str) and element.tag.lower() == "form":
506
+ submit_attr = None
507
+ model_attr = None
508
+ for attr in node.special_attributes:
509
+ if isinstance(attr, EventAttribute) and attr.event_type == "submit":
510
+ submit_attr = attr
511
+ elif isinstance(attr, ModelAttribute):
512
+ model_attr = attr
513
+
514
+ if submit_attr:
515
+ # Build validation schema from form inputs
516
+ schema = self._extract_form_validation_schema(node)
517
+ if model_attr:
518
+ schema.model_name = model_attr.model_name
519
+ submit_attr.validation_schema = schema
520
+
521
+ return node
522
+
523
+ def _extract_form_validation_schema(
524
+ self, form_node: TemplateNode
525
+ ) -> FormValidationSchema:
526
+ """Extract validation rules from form inputs."""
527
+ schema = FormValidationSchema()
528
+
529
+ def visit_node(node: TemplateNode) -> None:
530
+ if not node.tag:
531
+ return
532
+
533
+ tag_lower = node.tag.lower()
534
+
535
+ # Check for input, textarea, select with name attribute
536
+ if tag_lower in ("input", "textarea", "select"):
537
+ name = node.attributes.get("name")
538
+ if name:
539
+ rules = self._extract_field_rules(node, name)
540
+ schema.fields[name] = rules
541
+
542
+ # Recurse into children
543
+ for child in node.children:
544
+ visit_node(child)
545
+
546
+ for child in form_node.children:
547
+ visit_node(child)
548
+
549
+ return schema
550
+
551
+ def _extract_field_rules(
552
+ self, node: TemplateNode, field_name: str
553
+ ) -> FieldValidationRules:
554
+ """Extract validation rules from a single input node."""
555
+ attrs = node.attributes
556
+ special_attrs = node.special_attributes
557
+
558
+ rules = FieldValidationRules(name=field_name)
559
+
560
+ # Required - static or reactive
561
+ if "required" in attrs:
562
+ rules.required = True
563
+
564
+ # Pattern
565
+ if "pattern" in attrs:
566
+ rules.pattern = attrs["pattern"]
567
+
568
+ # Length constraints
569
+ if "minlength" in attrs:
570
+ try:
571
+ rules.minlength = int(attrs["minlength"])
572
+ except ValueError:
573
+ pass
574
+ if "maxlength" in attrs:
575
+ try:
576
+ rules.maxlength = int(attrs["maxlength"])
577
+ except ValueError:
578
+ pass
579
+
580
+ # Min/max (for number, date, etc.)
581
+ if "min" in attrs:
582
+ rules.min_value = attrs["min"]
583
+ if "max" in attrs:
584
+ rules.max_value = attrs["max"]
585
+
586
+ # Step
587
+ if "step" in attrs:
588
+ rules.step = attrs["step"]
589
+
590
+ # Input type
591
+ if "type" in attrs:
592
+ rules.input_type = attrs["type"].lower()
593
+ elif node.tag and node.tag.lower() == "textarea":
594
+ rules.input_type = "textarea"
595
+ elif node.tag and node.tag.lower() == "select":
596
+ rules.input_type = "select"
597
+
598
+ # Title (custom error message)
599
+ if "title" in attrs:
600
+ rules.title = attrs["title"]
601
+
602
+ # File validation
603
+ if "accept" in attrs:
604
+ # Split by comma
605
+ rules.allowed_types = [t.strip() for t in attrs["accept"].split(",")]
606
+
607
+ if "max-size" in attrs:
608
+ val = attrs["max-size"].lower().strip()
609
+ multiplier = 1
610
+ if val.endswith("kb") or val.endswith("k"):
611
+ multiplier = 1024
612
+ val = val.rstrip("kb")
613
+ elif val.endswith("mb") or val.endswith("m"):
614
+ multiplier = 1024 * 1024
615
+ val = val.rstrip("mb")
616
+ elif val.endswith("gb") or val.endswith("g"):
617
+ multiplier = 1024 * 1024 * 1024
618
+ val = val.rstrip("gb")
619
+
620
+ try:
621
+ rules.max_size = int(float(val) * multiplier)
622
+ except ValueError:
623
+ pass
624
+
625
+ # Check for reactive validation attributes (:required, :min, :max)
626
+ from pywire.compiler.ast_nodes import ReactiveAttribute
627
+
628
+ for attr in special_attrs:
629
+ if isinstance(attr, ReactiveAttribute):
630
+ if attr.name == "required":
631
+ rules.required_expr = attr.expr
632
+ elif attr.name == "min":
633
+ rules.min_expr = attr.expr
634
+ elif attr.name == "max":
635
+ rules.max_expr = attr.expr
636
+
637
+ return rules
638
+
639
+ def _parse_attributes(
640
+ self, attrs: Dict[str, Any]
641
+ ) -> Tuple[dict, List[Union[SpecialAttribute, InterpolationNode]]]:
642
+ """Separate regular attrs from special ones."""
643
+ regular = {}
644
+ special: List[Union[SpecialAttribute, InterpolationNode]] = []
645
+
646
+ for name, value in attrs.items():
647
+ if value is None:
648
+ value = ""
649
+
650
+ parsed = False
651
+ for parser in self.attribute_parsers:
652
+ if parser.can_parse(name):
653
+ attr = parser.parse(name, str(value), 0, 0)
654
+ if attr:
655
+ special.append(attr)
656
+ parsed = True
657
+ break
658
+
659
+ if not parsed:
660
+ # Check for reactive value syntax: attr="{expr}"
661
+ val_str = str(value).strip()
662
+ if (
663
+ val_str.startswith("{")
664
+ and val_str.endswith("}")
665
+ and val_str.count("{") == 1
666
+ ):
667
+ # Treat as reactive attribute
668
+ # Exclude special internal attr for spread
669
+ if name == "__pywire_spread__":
670
+ special.append(
671
+ SpreadAttribute(
672
+ name=name,
673
+ value=val_str,
674
+ expr=val_str[3:-1], # Strip {** and }
675
+ line=0,
676
+ column=0,
677
+ )
678
+ )
679
+ else:
680
+ special.append(
681
+ ReactiveAttribute(
682
+ name=name,
683
+ value=val_str,
684
+ expr=val_str[1:-1],
685
+ line=0,
686
+ column=0,
687
+ )
688
+ )
689
+ else:
690
+ regular[name] = val_str
691
+
692
+ return regular, special
693
+
694
+ def _looks_like_python_code(self, line: str) -> bool:
695
+ """Check if a line looks like Python code."""
696
+ if not line:
697
+ return False
698
+
699
+ # Skip HTML-like lines
700
+ if line.startswith("<") or line.endswith(">"):
701
+ return False
702
+
703
+ # Check for common Python patterns
704
+ python_patterns = [
705
+ line.startswith("def "),
706
+ line.startswith("class "),
707
+ line.startswith("import "),
708
+ line.startswith("from "),
709
+ line.startswith("async def "),
710
+ line.startswith("@"), # Decorators
711
+ # Assignment (but be careful not to match HTML attributes)
712
+ (
713
+ "=" in line
714
+ and not line.strip().startswith("<")
715
+ and ":" not in line[: line.find("=")]
716
+ ),
717
+ ]
718
+ return any(python_patterns)
719
+
720
+ def _validate_no_orphaned_python(self, lines: List[str], file_path: str) -> None:
721
+ """Validate that there's no malformed separator or orphaned Python code."""
722
+ for i, line in enumerate(lines):
723
+ stripped = line.strip()
724
+ if not stripped:
725
+ continue
726
+
727
+ if self._separator_re.match(stripped):
728
+ continue
729
+
730
+ if self._looks_like_separator_line(stripped):
731
+ raise PyWireSyntaxError(
732
+ f"Malformed separator on line {i + 1}: '{stripped}'. "
733
+ "Expected symmetric dashes around 'html', e.g. '---html---'.",
734
+ file_path=file_path,
735
+ line=i + 1,
736
+ )
737
+
738
+ # Check for Python-like code without proper separator
739
+ # Only check after line 5 to allow for directives at the top
740
+ if i > 5 and self._looks_like_python_code(stripped):
741
+ raise PyWireSyntaxError(
742
+ f"Python code detected on line {i + 1} without a '---html---' separator. "
743
+ f"Page-level Python code must appear before the HTML separator.\n"
744
+ f"Example format:\n"
745
+ f" # Python code here\n"
746
+ f" ---html---\n"
747
+ f" <div>HTML content</div>",
748
+ file_path=file_path,
749
+ line=i + 1,
750
+ )