pywire 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pywire-0.1.0.dist-info → pywire-0.1.1.dist-info}/METADATA +23 -1
- pywire-0.1.1.dist-info/RECORD +9 -0
- pywire/__init__.py +0 -2
- pywire/cli/__init__.py +0 -1
- pywire/cli/generators.py +0 -48
- pywire/cli/main.py +0 -309
- pywire/cli/tui.py +0 -563
- pywire/cli/validate.py +0 -26
- pywire/client/.prettierignore +0 -8
- pywire/client/.prettierrc +0 -7
- pywire/client/build.mjs +0 -73
- pywire/client/eslint.config.js +0 -46
- pywire/client/package.json +0 -39
- pywire/client/pnpm-lock.yaml +0 -2971
- pywire/client/src/core/app.ts +0 -263
- pywire/client/src/core/dom-updater.test.ts +0 -78
- pywire/client/src/core/dom-updater.ts +0 -321
- pywire/client/src/core/index.ts +0 -5
- pywire/client/src/core/transport-manager.test.ts +0 -179
- pywire/client/src/core/transport-manager.ts +0 -159
- pywire/client/src/core/transports/base.ts +0 -122
- pywire/client/src/core/transports/http.ts +0 -142
- pywire/client/src/core/transports/index.ts +0 -13
- pywire/client/src/core/transports/websocket.ts +0 -97
- pywire/client/src/core/transports/webtransport.ts +0 -149
- pywire/client/src/dev/dev-app.ts +0 -93
- pywire/client/src/dev/error-trace.test.ts +0 -97
- pywire/client/src/dev/error-trace.ts +0 -76
- pywire/client/src/dev/index.ts +0 -4
- pywire/client/src/dev/status-overlay.ts +0 -63
- pywire/client/src/events/handler.test.ts +0 -318
- pywire/client/src/events/handler.ts +0 -454
- pywire/client/src/pywire.core.ts +0 -22
- pywire/client/src/pywire.dev.ts +0 -27
- pywire/client/tsconfig.json +0 -17
- pywire/client/vitest.config.ts +0 -15
- pywire/compiler/__init__.py +0 -6
- pywire/compiler/ast_nodes.py +0 -304
- pywire/compiler/attributes/__init__.py +0 -6
- pywire/compiler/attributes/base.py +0 -24
- pywire/compiler/attributes/conditional.py +0 -37
- pywire/compiler/attributes/events.py +0 -55
- pywire/compiler/attributes/form.py +0 -37
- pywire/compiler/attributes/loop.py +0 -75
- pywire/compiler/attributes/reactive.py +0 -34
- pywire/compiler/build.py +0 -28
- pywire/compiler/build_artifacts.py +0 -342
- pywire/compiler/codegen/__init__.py +0 -5
- pywire/compiler/codegen/attributes/__init__.py +0 -6
- pywire/compiler/codegen/attributes/base.py +0 -19
- pywire/compiler/codegen/attributes/events.py +0 -35
- pywire/compiler/codegen/directives/__init__.py +0 -6
- pywire/compiler/codegen/directives/base.py +0 -16
- pywire/compiler/codegen/directives/path.py +0 -53
- pywire/compiler/codegen/generator.py +0 -2341
- pywire/compiler/codegen/template.py +0 -2178
- pywire/compiler/directives/__init__.py +0 -7
- pywire/compiler/directives/base.py +0 -20
- pywire/compiler/directives/component.py +0 -33
- pywire/compiler/directives/context.py +0 -93
- pywire/compiler/directives/layout.py +0 -49
- pywire/compiler/directives/no_spa.py +0 -24
- pywire/compiler/directives/path.py +0 -71
- pywire/compiler/directives/props.py +0 -88
- pywire/compiler/exceptions.py +0 -19
- pywire/compiler/interpolation/__init__.py +0 -6
- pywire/compiler/interpolation/base.py +0 -28
- pywire/compiler/interpolation/jinja.py +0 -272
- pywire/compiler/parser.py +0 -750
- pywire/compiler/paths.py +0 -29
- pywire/compiler/preprocessor.py +0 -43
- pywire/core/wire.py +0 -119
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +0 -7
- pywire/runtime/aioquic_server.py +0 -194
- pywire/runtime/app.py +0 -889
- pywire/runtime/compile_error_page.py +0 -195
- pywire/runtime/debug.py +0 -203
- pywire/runtime/dev_server.py +0 -434
- pywire/runtime/dev_server.py.broken +0 -268
- pywire/runtime/error_page.py +0 -64
- pywire/runtime/error_renderer.py +0 -23
- pywire/runtime/escape.py +0 -23
- pywire/runtime/files.py +0 -40
- pywire/runtime/helpers.py +0 -97
- pywire/runtime/http_transport.py +0 -253
- pywire/runtime/loader.py +0 -272
- pywire/runtime/logging.py +0 -72
- pywire/runtime/page.py +0 -384
- pywire/runtime/pydantic_integration.py +0 -52
- pywire/runtime/router.py +0 -229
- pywire/runtime/server.py +0 -25
- pywire/runtime/style_collector.py +0 -31
- pywire/runtime/upload_manager.py +0 -76
- pywire/runtime/validation.py +0 -449
- pywire/runtime/websocket.py +0 -665
- pywire/runtime/webtransport_handler.py +0 -195
- pywire-0.1.0.dist-info/RECORD +0 -104
- {pywire-0.1.0.dist-info → pywire-0.1.1.dist-info}/WHEEL +0 -0
- {pywire-0.1.0.dist-info → pywire-0.1.1.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.0.dist-info → pywire-0.1.1.dist-info}/licenses/LICENSE +0 -0
pywire/compiler/parser.py
DELETED
|
@@ -1,750 +0,0 @@
|
|
|
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
|
-
)
|