pywire 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pywire/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +889 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +434 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- pywire/templates/error/404.html +11 -0
- pywire/templates/error/500.html +38 -0
- pywire/templates/error/base.html +207 -0
- pywire/templates/error/compile_error.html +31 -0
- pywire-0.1.0.dist-info/METADATA +50 -0
- pywire-0.1.0.dist-info/RECORD +104 -0
- pywire-0.1.0.dist-info/WHEEL +4 -0
- pywire-0.1.0.dist-info/entry_points.txt +2 -0
- pywire-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Style collector for scoped CSS."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StyleCollector:
|
|
7
|
+
"""Collects and deduplicates scoped styles per request."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._styles: Dict[str, str] = {} # scope_id -> css
|
|
11
|
+
|
|
12
|
+
def add(self, scope_id: str, css: str) -> bool:
|
|
13
|
+
"""Add CSS for scope_id. Returns True if new (first occurrence)."""
|
|
14
|
+
if scope_id in self._styles:
|
|
15
|
+
return False
|
|
16
|
+
self._styles[scope_id] = css
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
def render(self) -> str:
|
|
20
|
+
"""Render all collected styles as a single <style> block."""
|
|
21
|
+
if not self._styles:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
# Sort by scope_id to ensure deterministic output (important for tests)
|
|
25
|
+
# But maybe preserving insertion order is better?
|
|
26
|
+
# Actually insertion order implicitly handles dependency order if children render first?
|
|
27
|
+
# No, children render inside parent body.
|
|
28
|
+
# But we collect during render.
|
|
29
|
+
# Let's just join values. Dicts preserve insertion order in modern Python.
|
|
30
|
+
combined = "\n".join(self._styles.values())
|
|
31
|
+
return f"<style>{combined}</style>"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from starlette.datastructures import UploadFile
|
|
8
|
+
|
|
9
|
+
from pywire.runtime.files import FileUpload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UploadManager:
|
|
13
|
+
"""
|
|
14
|
+
Manages temporary storage of uploaded files using generated IDs.
|
|
15
|
+
Files are stored in a temporary directory and accessed by ID.
|
|
16
|
+
Ideally, these should be cleaned up after request processing or via a TTL mechanism.
|
|
17
|
+
For this implementation, we rely on OS temp cleaning or process restart for now.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._temp_dir = Path(tempfile.mkdtemp(prefix="pywire_uploads_"))
|
|
22
|
+
|
|
23
|
+
def save(self, file: UploadFile) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Save an uploaded file and return a unique ID.
|
|
26
|
+
"""
|
|
27
|
+
upload_id = str(uuid.uuid4())
|
|
28
|
+
file_path = self._temp_dir / upload_id
|
|
29
|
+
|
|
30
|
+
# Save content
|
|
31
|
+
with open(file_path, "wb") as f:
|
|
32
|
+
shutil.copyfileobj(file.file, f)
|
|
33
|
+
|
|
34
|
+
# Store metadata in a separate file or just keep it simple?
|
|
35
|
+
# We need filename, content_type, size.
|
|
36
|
+
# Let's write a metadata file next to it.
|
|
37
|
+
# Format: filename\ncontent_type
|
|
38
|
+
# Size comes from file size.
|
|
39
|
+
|
|
40
|
+
meta_path = file_path.with_suffix(".meta")
|
|
41
|
+
with open(meta_path, "w") as f:
|
|
42
|
+
f.write(f"{file.filename}\n{file.content_type}")
|
|
43
|
+
|
|
44
|
+
return upload_id
|
|
45
|
+
|
|
46
|
+
def get(self, upload_id: str) -> Optional[FileUpload]:
|
|
47
|
+
"""
|
|
48
|
+
Retrieve a file by ID.
|
|
49
|
+
"""
|
|
50
|
+
file_path = self._temp_dir / upload_id
|
|
51
|
+
meta_path = file_path.with_suffix(".meta")
|
|
52
|
+
|
|
53
|
+
if not file_path.exists() or not meta_path.exists():
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with open(meta_path, "r") as f:
|
|
58
|
+
lines = f.read().splitlines()
|
|
59
|
+
filename = lines[0]
|
|
60
|
+
content_type = (
|
|
61
|
+
lines[1] if len(lines) > 1 else "application/octet-stream"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return FileUpload(
|
|
65
|
+
filename=filename,
|
|
66
|
+
content_type=content_type,
|
|
67
|
+
size=file_path.stat().st_size,
|
|
68
|
+
content=file_path.read_bytes(),
|
|
69
|
+
)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(f"Error retrieving upload {upload_id}: {e}")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Global instance
|
|
76
|
+
upload_manager = UploadManager()
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Server-side form validation matching HTML5 constraints."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type
|
|
9
|
+
|
|
10
|
+
from pywire.runtime.files import FileUpload
|
|
11
|
+
from pywire.runtime.upload_manager import upload_manager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class FieldRules:
|
|
16
|
+
"""Runtime validation rules for a single field."""
|
|
17
|
+
|
|
18
|
+
required: bool = False
|
|
19
|
+
required_expr: Optional[str] = None # Python expression for conditional required
|
|
20
|
+
pattern: Optional[str] = None
|
|
21
|
+
minlength: Optional[int] = None
|
|
22
|
+
maxlength: Optional[int] = None
|
|
23
|
+
min_value: Optional[str] = None
|
|
24
|
+
min_expr: Optional[str] = None # Python expression for dynamic min
|
|
25
|
+
max_value: Optional[str] = None
|
|
26
|
+
max_expr: Optional[str] = None # Python expression for dynamic max
|
|
27
|
+
step: Optional[str] = None
|
|
28
|
+
input_type: str = "text"
|
|
29
|
+
title: Optional[str] = None # Custom error message
|
|
30
|
+
max_size: Optional[int] = None # Max file size in bytes
|
|
31
|
+
allowed_types: Optional[List[str]] = None # Allowed MIME types or extensions
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FormValidationSchema:
|
|
36
|
+
"""Runtime schema containing all validation rules for a form."""
|
|
37
|
+
|
|
38
|
+
fields: Dict[str, FieldRules]
|
|
39
|
+
model_name: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FormValidator:
|
|
43
|
+
"""Server-side form validation matching HTML5 constraints."""
|
|
44
|
+
|
|
45
|
+
# Email regex (simplified but sufficient for most cases)
|
|
46
|
+
EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
|
47
|
+
|
|
48
|
+
# URL regex (simplified)
|
|
49
|
+
URL_PATTERN = re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
def validate_field(
|
|
52
|
+
self,
|
|
53
|
+
name: str,
|
|
54
|
+
value: Any,
|
|
55
|
+
rules: FieldRules,
|
|
56
|
+
state_getter: Optional[Callable[[str], Any]] = None,
|
|
57
|
+
) -> Optional[str]:
|
|
58
|
+
"""
|
|
59
|
+
Validate a single field against rules.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
name: Field name
|
|
63
|
+
value: Field value (string from form)
|
|
64
|
+
rules: Validation rules
|
|
65
|
+
state_getter: Optional function to evaluate expressions against page state
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Error message string, or None if valid.
|
|
69
|
+
"""
|
|
70
|
+
# Handle conditional required
|
|
71
|
+
is_required = rules.required
|
|
72
|
+
if rules.required_expr and state_getter:
|
|
73
|
+
try:
|
|
74
|
+
is_required = bool(state_getter(rules.required_expr))
|
|
75
|
+
except Exception:
|
|
76
|
+
is_required = rules.required
|
|
77
|
+
|
|
78
|
+
# Check required
|
|
79
|
+
if is_required:
|
|
80
|
+
if value is None or (isinstance(value, str) and value.strip() == ""):
|
|
81
|
+
return rules.title or "This field is required"
|
|
82
|
+
|
|
83
|
+
# If empty and not required, skip other validations
|
|
84
|
+
if value is None or (isinstance(value, str) and value.strip() == ""):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
str_value = str(value).strip()
|
|
88
|
+
|
|
89
|
+
# Pattern validation
|
|
90
|
+
if rules.pattern:
|
|
91
|
+
try:
|
|
92
|
+
if not re.fullmatch(rules.pattern, str_value):
|
|
93
|
+
return rules.title or "Value does not match the required pattern"
|
|
94
|
+
except re.error:
|
|
95
|
+
pass # Invalid regex, skip
|
|
96
|
+
|
|
97
|
+
# Length validations
|
|
98
|
+
if rules.minlength is not None:
|
|
99
|
+
if len(str_value) < rules.minlength:
|
|
100
|
+
return rules.title or f"Must be at least {rules.minlength} characters"
|
|
101
|
+
|
|
102
|
+
if rules.maxlength is not None:
|
|
103
|
+
if len(str_value) > rules.maxlength:
|
|
104
|
+
return rules.title or f"Must be at most {rules.maxlength} characters"
|
|
105
|
+
|
|
106
|
+
# Type-based validation
|
|
107
|
+
if rules.input_type == "email":
|
|
108
|
+
if not self.EMAIL_PATTERN.match(str_value):
|
|
109
|
+
return rules.title or "Please enter a valid email address"
|
|
110
|
+
|
|
111
|
+
elif rules.input_type == "url":
|
|
112
|
+
if not self.URL_PATTERN.match(str_value):
|
|
113
|
+
return rules.title or "Please enter a valid URL"
|
|
114
|
+
|
|
115
|
+
elif rules.input_type == "number":
|
|
116
|
+
return self._validate_number(str_value, rules, state_getter)
|
|
117
|
+
|
|
118
|
+
elif rules.input_type == "date":
|
|
119
|
+
return self._validate_date(str_value, rules, state_getter)
|
|
120
|
+
|
|
121
|
+
elif rules.input_type == "file":
|
|
122
|
+
# File validation
|
|
123
|
+
if isinstance(value, FileUpload):
|
|
124
|
+
# Check size
|
|
125
|
+
if rules.max_size is not None and value.size > rules.max_size:
|
|
126
|
+
size_mb = rules.max_size / (1024 * 1024)
|
|
127
|
+
return rules.title or f"File is too large (max {size_mb:.1f}MB)"
|
|
128
|
+
|
|
129
|
+
# Check type
|
|
130
|
+
if rules.allowed_types:
|
|
131
|
+
# Simple MIME type check
|
|
132
|
+
# rules.allowed_types is list like ['image/*', 'application/pdf', '.jpg']
|
|
133
|
+
allowed = False
|
|
134
|
+
for pattern in rules.allowed_types:
|
|
135
|
+
pattern = pattern.strip()
|
|
136
|
+
if pattern.startswith("."):
|
|
137
|
+
# Extension check
|
|
138
|
+
if value.filename.lower().endswith(pattern.lower()):
|
|
139
|
+
allowed = True
|
|
140
|
+
break
|
|
141
|
+
elif pattern.endswith("/*"):
|
|
142
|
+
# Wildcard MIME check (e.g. image/*)
|
|
143
|
+
base_type = pattern[:-2] # remove /*
|
|
144
|
+
if value.content_type.startswith(base_type):
|
|
145
|
+
allowed = True
|
|
146
|
+
break
|
|
147
|
+
else:
|
|
148
|
+
# Exact MIME check
|
|
149
|
+
if value.content_type == pattern:
|
|
150
|
+
allowed = True
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if not allowed:
|
|
154
|
+
return (
|
|
155
|
+
rules.title
|
|
156
|
+
or f"File type not allowed. Accepted: {', '.join(rules.allowed_types)}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Range validation for non-typed fields
|
|
160
|
+
if rules.input_type == "text":
|
|
161
|
+
# Only apply if min/max look numeric
|
|
162
|
+
if rules.min_value or rules.max_value or rules.min_expr or rules.max_expr:
|
|
163
|
+
try:
|
|
164
|
+
num_value = Decimal(str_value)
|
|
165
|
+
return self._validate_numeric_range(num_value, rules, state_getter)
|
|
166
|
+
except InvalidOperation:
|
|
167
|
+
pass # Not a number, skip range validation
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def _validate_number(
|
|
172
|
+
self,
|
|
173
|
+
str_value: str,
|
|
174
|
+
rules: FieldRules,
|
|
175
|
+
state_getter: Optional[Callable[[str], Any]] = None,
|
|
176
|
+
) -> Optional[str]:
|
|
177
|
+
"""Validate a number input."""
|
|
178
|
+
try:
|
|
179
|
+
num_value = Decimal(str_value)
|
|
180
|
+
except InvalidOperation:
|
|
181
|
+
return rules.title or "Please enter a valid number"
|
|
182
|
+
|
|
183
|
+
return self._validate_numeric_range(num_value, rules, state_getter)
|
|
184
|
+
|
|
185
|
+
def _validate_numeric_range(
|
|
186
|
+
self,
|
|
187
|
+
num_value: Decimal,
|
|
188
|
+
rules: FieldRules,
|
|
189
|
+
state_getter: Optional[Callable[[str], Any]] = None,
|
|
190
|
+
) -> Optional[str]:
|
|
191
|
+
"""Validate numeric range constraints."""
|
|
192
|
+
# Get min value (static or dynamic)
|
|
193
|
+
min_val = None
|
|
194
|
+
if rules.min_expr and state_getter:
|
|
195
|
+
try:
|
|
196
|
+
min_val = Decimal(str(state_getter(rules.min_expr)))
|
|
197
|
+
except (InvalidOperation, Exception):
|
|
198
|
+
pass
|
|
199
|
+
elif rules.min_value:
|
|
200
|
+
try:
|
|
201
|
+
min_val = Decimal(rules.min_value)
|
|
202
|
+
except InvalidOperation:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
if min_val is not None and num_value < min_val:
|
|
206
|
+
return rules.title or f"Value must be at least {min_val}"
|
|
207
|
+
|
|
208
|
+
# Get max value (static or dynamic)
|
|
209
|
+
max_val = None
|
|
210
|
+
if rules.max_expr and state_getter:
|
|
211
|
+
try:
|
|
212
|
+
max_val = Decimal(str(state_getter(rules.max_expr)))
|
|
213
|
+
except (InvalidOperation, Exception):
|
|
214
|
+
pass
|
|
215
|
+
elif rules.max_value:
|
|
216
|
+
try:
|
|
217
|
+
max_val = Decimal(rules.max_value)
|
|
218
|
+
except InvalidOperation:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
if max_val is not None and num_value > max_val:
|
|
222
|
+
return rules.title or f"Value must be at most {max_val}"
|
|
223
|
+
|
|
224
|
+
# Step validation
|
|
225
|
+
if rules.step:
|
|
226
|
+
try:
|
|
227
|
+
step = Decimal(rules.step)
|
|
228
|
+
if step > 0:
|
|
229
|
+
base = min_val if min_val is not None else Decimal("0")
|
|
230
|
+
diff = num_value - base
|
|
231
|
+
if diff % step != 0:
|
|
232
|
+
return rules.title or f"Value must be a multiple of {step}"
|
|
233
|
+
except InvalidOperation:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
def _validate_date(
|
|
239
|
+
self,
|
|
240
|
+
str_value: str,
|
|
241
|
+
rules: FieldRules,
|
|
242
|
+
state_getter: Optional[Callable[[str], Any]] = None,
|
|
243
|
+
) -> Optional[str]:
|
|
244
|
+
"""Validate a date input."""
|
|
245
|
+
try:
|
|
246
|
+
date_value = date.fromisoformat(str_value)
|
|
247
|
+
except ValueError:
|
|
248
|
+
return rules.title or "Please enter a valid date (YYYY-MM-DD)"
|
|
249
|
+
|
|
250
|
+
# Get min date (static or dynamic)
|
|
251
|
+
min_date = None
|
|
252
|
+
if rules.min_expr and state_getter:
|
|
253
|
+
try:
|
|
254
|
+
min_str = str(state_getter(rules.min_expr))
|
|
255
|
+
min_date = date.fromisoformat(min_str)
|
|
256
|
+
except (ValueError, Exception):
|
|
257
|
+
pass
|
|
258
|
+
elif rules.min_value:
|
|
259
|
+
try:
|
|
260
|
+
min_date = date.fromisoformat(rules.min_value)
|
|
261
|
+
except ValueError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
if min_date is not None and date_value < min_date:
|
|
265
|
+
return rules.title or f"Date must be on or after {min_date.isoformat()}"
|
|
266
|
+
|
|
267
|
+
# Get max date (static or dynamic)
|
|
268
|
+
max_date = None
|
|
269
|
+
if rules.max_expr and state_getter:
|
|
270
|
+
try:
|
|
271
|
+
max_str = str(state_getter(rules.max_expr))
|
|
272
|
+
max_date = date.fromisoformat(max_str)
|
|
273
|
+
except (ValueError, Exception):
|
|
274
|
+
pass
|
|
275
|
+
elif rules.max_value:
|
|
276
|
+
try:
|
|
277
|
+
max_date = date.fromisoformat(rules.max_value)
|
|
278
|
+
except ValueError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
if max_date is not None and date_value > max_date:
|
|
282
|
+
return rules.title or f"Date must be on or before {max_date.isoformat()}"
|
|
283
|
+
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def validate_form(
|
|
287
|
+
self,
|
|
288
|
+
data: Dict[str, Any],
|
|
289
|
+
schema: Dict[str, FieldRules],
|
|
290
|
+
state_getter: Callable[[str], Any],
|
|
291
|
+
) -> Tuple[Dict[str, Any], Dict[str, str]]:
|
|
292
|
+
"""
|
|
293
|
+
Validate data against schema.
|
|
294
|
+
Returns: (cleaned_data, errors)
|
|
295
|
+
"""
|
|
296
|
+
errors: Dict[str, str] = {}
|
|
297
|
+
cleaned_data: Dict[str, Any] = {}
|
|
298
|
+
|
|
299
|
+
# 1. Validate fields present in schema
|
|
300
|
+
for field_name, rules in schema.items():
|
|
301
|
+
value = data.get(field_name)
|
|
302
|
+
|
|
303
|
+
# Helper to evaluate rules against state
|
|
304
|
+
def eval_rule(attr_name: str) -> Any:
|
|
305
|
+
expr = getattr(rules, f"{attr_name}_expr")
|
|
306
|
+
if expr:
|
|
307
|
+
return state_getter(expr)
|
|
308
|
+
return getattr(rules, attr_name)
|
|
309
|
+
|
|
310
|
+
# Check required
|
|
311
|
+
is_required = eval_rule("required")
|
|
312
|
+
if is_required and (value is None or value == ""):
|
|
313
|
+
errors[field_name] = f"{rules.title or field_name} is required."
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# If empty and not required, skip other validations
|
|
317
|
+
if value is None or value == "":
|
|
318
|
+
if rules.input_type == "checkbox":
|
|
319
|
+
cleaned_data[field_name] = False
|
|
320
|
+
else:
|
|
321
|
+
cleaned_data[field_name] = None
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
# Type conversion (strings to int/float/bool)
|
|
325
|
+
try:
|
|
326
|
+
converted_value = self._convert_value(value, rules.input_type)
|
|
327
|
+
cleaned_data[field_name] = converted_value
|
|
328
|
+
except ValueError:
|
|
329
|
+
errors[field_name] = (
|
|
330
|
+
f"{rules.title or field_name} must be a valid {rules.input_type}."
|
|
331
|
+
)
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
# Validate rules against converted value
|
|
335
|
+
error = self.validate_field(
|
|
336
|
+
field_name, converted_value, rules, state_getter
|
|
337
|
+
) # Use original state_getter for validate_field
|
|
338
|
+
if error:
|
|
339
|
+
errors[field_name] = error
|
|
340
|
+
|
|
341
|
+
# 2. Pass through data not in schema?
|
|
342
|
+
# For strict typing, maybe we only want schema fields?
|
|
343
|
+
# But for flexibility, let's merge original data for non-schema fields.
|
|
344
|
+
final_data = data.copy()
|
|
345
|
+
final_data.update(cleaned_data)
|
|
346
|
+
|
|
347
|
+
return final_data, errors
|
|
348
|
+
|
|
349
|
+
def _convert_value(self, value: Any, input_type: str) -> Any:
|
|
350
|
+
"""Convert string value to appropriate type."""
|
|
351
|
+
if value is None or value == "":
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
if input_type == "number":
|
|
355
|
+
# Try int first, then float? Or just float?
|
|
356
|
+
# HTML input type="number" can be either.
|
|
357
|
+
try:
|
|
358
|
+
if isinstance(value, str) and "." in value:
|
|
359
|
+
return float(value)
|
|
360
|
+
return int(value)
|
|
361
|
+
except ValueError:
|
|
362
|
+
# If int conversion fails, try float as a last resort
|
|
363
|
+
return float(value)
|
|
364
|
+
|
|
365
|
+
elif input_type == "checkbox":
|
|
366
|
+
# Checkbox value usually "on" or "true" string, but handled by client framework?
|
|
367
|
+
# If it comes from FormData, unchecked might be missing (handled in
|
|
368
|
+
# validate_form required check).
|
|
369
|
+
# Checked might be "on".
|
|
370
|
+
# Convert to boolean. Common values for "true" are "on", "true", 1.
|
|
371
|
+
if isinstance(value, str):
|
|
372
|
+
return value.lower() in ("on", "true", "1")
|
|
373
|
+
return bool(value)
|
|
374
|
+
|
|
375
|
+
elif input_type == "file":
|
|
376
|
+
# File uploads come as dicts from client.
|
|
377
|
+
# If it has _upload_id, resolve it via UploadManager.
|
|
378
|
+
# If it has content (old way), use from_dict.
|
|
379
|
+
if isinstance(value, dict):
|
|
380
|
+
if "_upload_id" in value:
|
|
381
|
+
file = upload_manager.get(value["_upload_id"])
|
|
382
|
+
if file:
|
|
383
|
+
return file
|
|
384
|
+
return None # Pending or expired?
|
|
385
|
+
elif "content" in value:
|
|
386
|
+
return FileUpload.from_dict(value)
|
|
387
|
+
return value
|
|
388
|
+
|
|
389
|
+
return value
|
|
390
|
+
|
|
391
|
+
def convert_to_type(self, value: Any, target_type: Type) -> Any:
|
|
392
|
+
"""Convert value to specific Python type hints (e.g. Enums)."""
|
|
393
|
+
if value is None:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
# Enum conversion
|
|
397
|
+
if isinstance(target_type, type) and issubclass(target_type, Enum):
|
|
398
|
+
# Try matching by value first
|
|
399
|
+
try:
|
|
400
|
+
return target_type(value)
|
|
401
|
+
except ValueError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
# Try matching by name
|
|
405
|
+
if isinstance(value, str):
|
|
406
|
+
try:
|
|
407
|
+
return target_type[value]
|
|
408
|
+
except KeyError:
|
|
409
|
+
pass
|
|
410
|
+
try:
|
|
411
|
+
return target_type[value.upper()]
|
|
412
|
+
except KeyError:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
# Return original if generic match fails, likely invalid
|
|
416
|
+
return value
|
|
417
|
+
|
|
418
|
+
return value
|
|
419
|
+
|
|
420
|
+
return value
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def parse_nested_data(flat_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
424
|
+
"""
|
|
425
|
+
Parse flat form data with dot notation into nested dicts.
|
|
426
|
+
|
|
427
|
+
Example:
|
|
428
|
+
{'customer.name': 'John', 'customer.email': 'john@example.com'}
|
|
429
|
+
->
|
|
430
|
+
{'customer': {'name': 'John', 'email': 'john@example.com'}}
|
|
431
|
+
"""
|
|
432
|
+
result: Dict[str, Any] = {}
|
|
433
|
+
|
|
434
|
+
for key, value in flat_data.items():
|
|
435
|
+
parts = key.split(".")
|
|
436
|
+
current = result
|
|
437
|
+
|
|
438
|
+
for i, part in enumerate(parts[:-1]):
|
|
439
|
+
if part not in current:
|
|
440
|
+
current[part] = {}
|
|
441
|
+
current = current[part]
|
|
442
|
+
|
|
443
|
+
current[parts[-1]] = value
|
|
444
|
+
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Global validator instance
|
|
449
|
+
form_validator = FormValidator()
|