pyview-web 0.3.0__py3-none-any.whl → 0.8.0a2__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.
- pyview/__init__.py +16 -6
- pyview/assets/js/app.js +1 -0
- pyview/assets/js/uploaders.js +221 -0
- pyview/assets/package-lock.json +16 -14
- pyview/assets/package.json +2 -2
- pyview/async_stream_runner.py +2 -1
- pyview/auth/__init__.py +3 -1
- pyview/auth/provider.py +6 -6
- pyview/auth/required.py +7 -10
- pyview/binding/__init__.py +47 -0
- pyview/binding/binder.py +134 -0
- pyview/binding/context.py +33 -0
- pyview/binding/converters.py +191 -0
- pyview/binding/helpers.py +78 -0
- pyview/binding/injectables.py +119 -0
- pyview/binding/params.py +105 -0
- pyview/binding/result.py +32 -0
- pyview/changesets/__init__.py +2 -0
- pyview/changesets/changesets.py +8 -3
- pyview/cli/commands/create_view.py +4 -3
- pyview/cli/main.py +1 -1
- pyview/components/__init__.py +72 -0
- pyview/components/base.py +212 -0
- pyview/components/lifecycle.py +85 -0
- pyview/components/manager.py +366 -0
- pyview/components/renderer.py +14 -0
- pyview/components/slots.py +73 -0
- pyview/csrf.py +4 -2
- pyview/events/AutoEventDispatch.py +98 -0
- pyview/events/BaseEventHandler.py +51 -8
- pyview/events/__init__.py +2 -1
- pyview/instrumentation/__init__.py +3 -3
- pyview/instrumentation/interfaces.py +57 -33
- pyview/instrumentation/noop.py +21 -18
- pyview/js.py +20 -23
- pyview/live_routes.py +5 -3
- pyview/live_socket.py +167 -44
- pyview/live_view.py +24 -12
- pyview/meta.py +14 -2
- pyview/phx_message.py +7 -8
- pyview/playground/__init__.py +10 -0
- pyview/playground/builder.py +118 -0
- pyview/playground/favicon.py +39 -0
- pyview/pyview.py +54 -20
- pyview/session.py +2 -0
- pyview/static/assets/app.js +2088 -806
- pyview/static/assets/uploaders.js +221 -0
- pyview/stream.py +308 -0
- pyview/template/__init__.py +11 -1
- pyview/template/live_template.py +12 -8
- pyview/template/live_view_template.py +338 -0
- pyview/template/render_diff.py +33 -7
- pyview/template/root_template.py +21 -9
- pyview/template/serializer.py +2 -5
- pyview/template/template_view.py +170 -0
- pyview/template/utils.py +3 -2
- pyview/uploads.py +344 -55
- pyview/vendor/flet/pubsub/__init__.py +3 -1
- pyview/vendor/flet/pubsub/pub_sub.py +10 -18
- pyview/vendor/ibis/__init__.py +3 -7
- pyview/vendor/ibis/compiler.py +25 -32
- pyview/vendor/ibis/context.py +13 -15
- pyview/vendor/ibis/errors.py +0 -6
- pyview/vendor/ibis/filters.py +70 -76
- pyview/vendor/ibis/loaders.py +6 -7
- pyview/vendor/ibis/nodes.py +40 -42
- pyview/vendor/ibis/template.py +4 -5
- pyview/vendor/ibis/tree.py +62 -3
- pyview/vendor/ibis/utils.py +14 -15
- pyview/ws_handler.py +116 -86
- {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
- pyview_web-0.8.0a2.dist-info/RECORD +80 -0
- pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
- pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
- pyview_web-0.3.0.dist-info/LICENSE +0 -21
- pyview_web-0.3.0.dist-info/RECORD +0 -58
- pyview_web-0.3.0.dist-info/WHEEL +0 -4
- pyview_web-0.3.0.dist-info/entry_points.txt +0 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Type conversion for parameter binding."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import types
|
|
5
|
+
from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConversionError(Exception):
|
|
9
|
+
"""Raised when type conversion fails."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConverterRegistry:
|
|
15
|
+
"""Handles type conversion from raw string values to typed parameters.
|
|
16
|
+
|
|
17
|
+
Supports:
|
|
18
|
+
- Primitives: int, float, str, bool
|
|
19
|
+
- Optional[T] and T | None: Returns None for missing/empty values
|
|
20
|
+
- Union[T1, T2, ...] and T1 | T2: Tries each variant in order
|
|
21
|
+
- Containers: list[T], set[T], tuple[T, ...]
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def convert(self, raw: Any, expected: Any) -> Any:
|
|
25
|
+
"""Convert raw value to expected type.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
raw: Raw value (usually str or list[str] from params)
|
|
29
|
+
expected: Target type annotation
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Converted value
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ConversionError: If conversion fails
|
|
36
|
+
"""
|
|
37
|
+
origin = get_origin(expected)
|
|
38
|
+
args = get_args(expected)
|
|
39
|
+
|
|
40
|
+
# Handle None/missing
|
|
41
|
+
if raw is None:
|
|
42
|
+
if self.is_optional(expected):
|
|
43
|
+
return None
|
|
44
|
+
raise ConversionError("Value is required")
|
|
45
|
+
|
|
46
|
+
# Handle Optional / Union (both typing.Union and types.UnionType for X | Y syntax)
|
|
47
|
+
if origin is Union or origin is types.UnionType:
|
|
48
|
+
return self._convert_union(raw, args)
|
|
49
|
+
|
|
50
|
+
# Handle list/set/tuple
|
|
51
|
+
if origin in (list, set, tuple):
|
|
52
|
+
return self._convert_container(raw, origin, args)
|
|
53
|
+
|
|
54
|
+
# Handle scalar primitives
|
|
55
|
+
if expected in (int, float, str, bool):
|
|
56
|
+
return self._convert_scalar(raw, expected)
|
|
57
|
+
|
|
58
|
+
# Handle dataclasses - construct from dict
|
|
59
|
+
if dataclasses.is_dataclass(expected) and isinstance(expected, type):
|
|
60
|
+
return self._convert_dataclass(raw, expected)
|
|
61
|
+
|
|
62
|
+
# Fallback: return as-is
|
|
63
|
+
return raw
|
|
64
|
+
|
|
65
|
+
def _convert_dataclass(self, raw: Any, expected: type) -> Any:
|
|
66
|
+
"""Convert dict to dataclass instance."""
|
|
67
|
+
if not isinstance(raw, dict):
|
|
68
|
+
raise ConversionError(
|
|
69
|
+
f"Expected dict for dataclass {expected.__name__}, got {type(raw).__name__}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Get type hints, falling back to empty for missing annotations
|
|
73
|
+
# NameError: forward reference can't be resolved
|
|
74
|
+
# AttributeError: accessing annotations on some objects
|
|
75
|
+
# RecursionError: circular type references
|
|
76
|
+
try:
|
|
77
|
+
hints = get_type_hints(expected)
|
|
78
|
+
except (NameError, AttributeError, RecursionError):
|
|
79
|
+
hints = {}
|
|
80
|
+
|
|
81
|
+
fields = dataclasses.fields(expected)
|
|
82
|
+
kwargs: dict[str, Any] = {}
|
|
83
|
+
|
|
84
|
+
missing_fields: list[str] = []
|
|
85
|
+
|
|
86
|
+
for field in fields:
|
|
87
|
+
field_type = hints.get(field.name, Any)
|
|
88
|
+
if field.name in raw:
|
|
89
|
+
kwargs[field.name] = self.convert(raw[field.name], field_type)
|
|
90
|
+
elif field.default is not dataclasses.MISSING:
|
|
91
|
+
kwargs[field.name] = field.default
|
|
92
|
+
elif field.default_factory is not dataclasses.MISSING:
|
|
93
|
+
kwargs[field.name] = field.default_factory()
|
|
94
|
+
elif self.is_optional(field_type):
|
|
95
|
+
kwargs[field.name] = None
|
|
96
|
+
else:
|
|
97
|
+
missing_fields.append(field.name)
|
|
98
|
+
|
|
99
|
+
if missing_fields:
|
|
100
|
+
raise ConversionError(
|
|
101
|
+
f"Missing required fields for {expected.__name__}: {', '.join(missing_fields)}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return expected(**kwargs)
|
|
105
|
+
|
|
106
|
+
def _convert_union(self, raw: Any, args: tuple[type, ...]) -> Any:
|
|
107
|
+
"""Try each union variant in order."""
|
|
108
|
+
errors: list[str] = []
|
|
109
|
+
for variant in args:
|
|
110
|
+
if variant is type(None):
|
|
111
|
+
# Handle empty string as None for Optional
|
|
112
|
+
if raw == "" or raw == [""]:
|
|
113
|
+
return None
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
return self.convert(raw, variant)
|
|
117
|
+
except (ConversionError, ValueError, TypeError) as e:
|
|
118
|
+
errors.append(str(e))
|
|
119
|
+
raise ConversionError(f"No union variant matched: {errors}")
|
|
120
|
+
|
|
121
|
+
def _convert_container(self, raw: Any, origin: type, args: tuple[type, ...]) -> Any:
|
|
122
|
+
"""Convert to list/set/tuple.
|
|
123
|
+
|
|
124
|
+
Handles:
|
|
125
|
+
- list[T], set[T]: homogeneous containers
|
|
126
|
+
- tuple[T, ...]: homogeneous variable-length tuple
|
|
127
|
+
- tuple[T1, T2, T3]: heterogeneous fixed-length tuple
|
|
128
|
+
"""
|
|
129
|
+
items = raw if isinstance(raw, list) else [raw]
|
|
130
|
+
|
|
131
|
+
if origin is tuple and args:
|
|
132
|
+
# Check for homogeneous tuple: tuple[T, ...] has Ellipsis as second arg
|
|
133
|
+
if len(args) == 2 and args[1] is ...:
|
|
134
|
+
inner = args[0]
|
|
135
|
+
converted = [self.convert(v, inner) for v in items]
|
|
136
|
+
else:
|
|
137
|
+
# Heterogeneous tuple: tuple[T1, T2, T3]
|
|
138
|
+
if len(items) != len(args):
|
|
139
|
+
raise ConversionError(
|
|
140
|
+
f"Expected {len(args)} values for tuple, got {len(items)}"
|
|
141
|
+
)
|
|
142
|
+
converted = [
|
|
143
|
+
self.convert(item, arg_type) for item, arg_type in zip(items, args, strict=True)
|
|
144
|
+
]
|
|
145
|
+
return tuple(converted)
|
|
146
|
+
|
|
147
|
+
# list[T] or set[T]: homogeneous
|
|
148
|
+
inner = args[0] if args else str
|
|
149
|
+
converted = [self.convert(v, inner) for v in items]
|
|
150
|
+
|
|
151
|
+
if origin is list:
|
|
152
|
+
return converted
|
|
153
|
+
if origin is set:
|
|
154
|
+
return set(converted)
|
|
155
|
+
return tuple(converted)
|
|
156
|
+
|
|
157
|
+
def _convert_scalar(self, raw: Any, expected: type) -> Any:
|
|
158
|
+
"""Convert to scalar type, picking first if input is a list."""
|
|
159
|
+
# Normalize: if list, take first element
|
|
160
|
+
if isinstance(raw, list):
|
|
161
|
+
if not raw:
|
|
162
|
+
raise ConversionError("Empty list for scalar type")
|
|
163
|
+
raw = raw[0]
|
|
164
|
+
|
|
165
|
+
if expected is bool:
|
|
166
|
+
return self._convert_bool(raw)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return expected(raw)
|
|
170
|
+
except (ValueError, TypeError) as e:
|
|
171
|
+
raise ConversionError(f"Cannot convert {raw!r} to {expected.__name__}: {e}") from e
|
|
172
|
+
|
|
173
|
+
def _convert_bool(self, raw: Any) -> bool:
|
|
174
|
+
"""Convert to boolean with common string values."""
|
|
175
|
+
if isinstance(raw, bool):
|
|
176
|
+
return raw
|
|
177
|
+
if isinstance(raw, (int, float)):
|
|
178
|
+
return bool(raw)
|
|
179
|
+
s = str(raw).lower().strip()
|
|
180
|
+
if s in ("true", "1", "yes", "on"):
|
|
181
|
+
return True
|
|
182
|
+
if s in ("false", "0", "no", "off", ""):
|
|
183
|
+
return False
|
|
184
|
+
raise ConversionError(f"Cannot convert {raw!r} to bool")
|
|
185
|
+
|
|
186
|
+
def is_optional(self, expected: Any) -> bool:
|
|
187
|
+
"""Check if type is Optional[T] (Union[T, None] or T | None)."""
|
|
188
|
+
origin = get_origin(expected)
|
|
189
|
+
if origin is Union or origin is types.UnionType:
|
|
190
|
+
return type(None) in get_args(expected)
|
|
191
|
+
return False
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Helper functions for runtime integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from urllib.parse import ParseResult
|
|
8
|
+
|
|
9
|
+
from .binder import Binder
|
|
10
|
+
from .context import BindContext
|
|
11
|
+
from .params import Params
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pyview.live_socket import LiveViewSocket
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def call_handle_params(
|
|
20
|
+
lv, url: ParseResult, params: dict[str, list[str]], socket: LiveViewSocket
|
|
21
|
+
):
|
|
22
|
+
"""Bind params and call handle_params with signature-matched args.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
lv: The LiveView instance
|
|
26
|
+
url: Parsed URL
|
|
27
|
+
params: Raw dict[str, list[str]] from parse_qs
|
|
28
|
+
socket: The socket instance
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Result of lv.handle_params()
|
|
32
|
+
"""
|
|
33
|
+
ctx = BindContext(
|
|
34
|
+
params=Params(params),
|
|
35
|
+
payload=None,
|
|
36
|
+
url=url,
|
|
37
|
+
socket=socket,
|
|
38
|
+
event=None,
|
|
39
|
+
)
|
|
40
|
+
binder = Binder()
|
|
41
|
+
result = binder.bind(lv.handle_params, ctx)
|
|
42
|
+
|
|
43
|
+
if not result.success:
|
|
44
|
+
for err in result.errors:
|
|
45
|
+
logger.warning(f"Param binding error: {err}")
|
|
46
|
+
raise ValueError(f"Parameter binding failed: {result.errors}")
|
|
47
|
+
|
|
48
|
+
return await lv.handle_params(**result.bound_args)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def call_handle_event(lv, event: str, payload: dict, socket: LiveViewSocket):
|
|
52
|
+
"""Bind event payload and call handle_event with signature-matched args.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
lv: The LiveView instance
|
|
56
|
+
event: Event name (e.g., "increment", "submit")
|
|
57
|
+
payload: Event payload dict
|
|
58
|
+
socket: The socket instance
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Result of lv.handle_event()
|
|
62
|
+
"""
|
|
63
|
+
ctx = BindContext(
|
|
64
|
+
params=Params({}),
|
|
65
|
+
payload=payload,
|
|
66
|
+
url=None,
|
|
67
|
+
socket=socket,
|
|
68
|
+
event=event,
|
|
69
|
+
)
|
|
70
|
+
binder = Binder()
|
|
71
|
+
result = binder.bind(lv.handle_event, ctx)
|
|
72
|
+
|
|
73
|
+
if not result.success:
|
|
74
|
+
for err in result.errors:
|
|
75
|
+
logger.warning(f"Event binding error: {err}")
|
|
76
|
+
raise ValueError(f"Event binding failed: {result.errors}")
|
|
77
|
+
|
|
78
|
+
return await lv.handle_event(**result.bound_args)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Injectable parameter resolution for special runtime objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, get_args, get_origin
|
|
6
|
+
|
|
7
|
+
from .converters import ConverterRegistry
|
|
8
|
+
from .params import Params
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .context import BindContext
|
|
12
|
+
|
|
13
|
+
# Sentinel to distinguish "not resolvable" from "resolved to None"
|
|
14
|
+
_NOT_FOUND = object()
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InjectableRegistry(Generic[T]):
|
|
20
|
+
"""Resolves special injectable parameters by name or type.
|
|
21
|
+
|
|
22
|
+
Injectables are parameters that come from the runtime context rather than
|
|
23
|
+
from user-provided params/payload. Examples: socket, event, payload, url, params.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def resolve(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
annotation: Any,
|
|
30
|
+
ctx: BindContext[T],
|
|
31
|
+
) -> Any | None:
|
|
32
|
+
"""Try to resolve a parameter from injectables.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
name: Parameter name
|
|
36
|
+
annotation: Parameter type annotation
|
|
37
|
+
ctx: Binding context with available values
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The injectable value, or None if not resolvable
|
|
41
|
+
"""
|
|
42
|
+
# Name-based injection
|
|
43
|
+
if name == "socket":
|
|
44
|
+
return ctx.socket
|
|
45
|
+
if name == "event":
|
|
46
|
+
return ctx.event
|
|
47
|
+
if name == "payload":
|
|
48
|
+
return ctx.payload
|
|
49
|
+
if name == "url":
|
|
50
|
+
return ctx.url
|
|
51
|
+
if name == "params":
|
|
52
|
+
# Only inject if typed as Params, dict, or untyped (Any)
|
|
53
|
+
# Otherwise treat "params" as a regular URL param name
|
|
54
|
+
if self._is_params_annotation(annotation):
|
|
55
|
+
return self._resolve_params(annotation, ctx)
|
|
56
|
+
return _NOT_FOUND
|
|
57
|
+
|
|
58
|
+
# Check extra injectables
|
|
59
|
+
if name in ctx.extra:
|
|
60
|
+
return ctx.extra[name]
|
|
61
|
+
|
|
62
|
+
return _NOT_FOUND
|
|
63
|
+
|
|
64
|
+
def _is_params_annotation(self, annotation: Any) -> bool:
|
|
65
|
+
"""Check if annotation indicates params injection vs URL param named 'params'."""
|
|
66
|
+
# Untyped (Any) -> inject for backward compat
|
|
67
|
+
if annotation is Any:
|
|
68
|
+
return True
|
|
69
|
+
# Explicit Params type
|
|
70
|
+
if annotation is Params:
|
|
71
|
+
return True
|
|
72
|
+
# dict or dict[...] -> inject
|
|
73
|
+
return annotation is dict or get_origin(annotation) is dict
|
|
74
|
+
|
|
75
|
+
def _resolve_params(self, annotation: Any, ctx: BindContext[T]) -> Any:
|
|
76
|
+
"""Resolve params parameter based on annotation type."""
|
|
77
|
+
origin = get_origin(annotation)
|
|
78
|
+
args = get_args(annotation)
|
|
79
|
+
|
|
80
|
+
# params: Params -> return wrapper
|
|
81
|
+
if annotation is Params:
|
|
82
|
+
return ctx.params
|
|
83
|
+
|
|
84
|
+
# Handle dict annotations
|
|
85
|
+
if annotation is dict or origin is dict:
|
|
86
|
+
# params: dict[str, list[str]] -> return raw
|
|
87
|
+
if args == (str, list[str]):
|
|
88
|
+
return ctx.params.raw()
|
|
89
|
+
|
|
90
|
+
# params: dict[str, Any] -> flatten
|
|
91
|
+
if len(args) == 2 and args[0] is str:
|
|
92
|
+
if args[1] is Any:
|
|
93
|
+
return ctx.params.to_flat_dict()
|
|
94
|
+
|
|
95
|
+
# params: dict[str, T] -> convert values
|
|
96
|
+
value_type = args[1]
|
|
97
|
+
return self._convert_dict_values(ctx.params, value_type)
|
|
98
|
+
|
|
99
|
+
# Bare dict or dict[str, list[str]] default
|
|
100
|
+
if not args:
|
|
101
|
+
return ctx.params.to_flat_dict()
|
|
102
|
+
|
|
103
|
+
# Default: return Params wrapper
|
|
104
|
+
return ctx.params
|
|
105
|
+
|
|
106
|
+
def _convert_dict_values(
|
|
107
|
+
self,
|
|
108
|
+
params: Params,
|
|
109
|
+
value_type: type,
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Convert all param values to specified type."""
|
|
112
|
+
converter = ConverterRegistry()
|
|
113
|
+
result: dict[str, Any] = {}
|
|
114
|
+
|
|
115
|
+
for key in params:
|
|
116
|
+
raw = params.getlist(key)
|
|
117
|
+
result[key] = converter.convert(raw, value_type)
|
|
118
|
+
|
|
119
|
+
return result
|
pyview/binding/params.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Multi-value parameter container for query/path/form params."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterator, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _as_list(value: Any) -> list[str]:
|
|
7
|
+
"""Convert a value to list[str].
|
|
8
|
+
|
|
9
|
+
Handles mixed sources:
|
|
10
|
+
- Query params from parse_qs() are already list[str]
|
|
11
|
+
- Path params from Starlette are single values (str or int)
|
|
12
|
+
"""
|
|
13
|
+
if isinstance(value, list):
|
|
14
|
+
return value
|
|
15
|
+
elif value is not None:
|
|
16
|
+
return [str(value)]
|
|
17
|
+
else:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Params:
|
|
22
|
+
"""Multi-value parameter container for query/path/form params.
|
|
23
|
+
|
|
24
|
+
This provides a convenient interface for accessing parameters that may have
|
|
25
|
+
multiple values (e.g., from query strings like ?tag=a&tag=b).
|
|
26
|
+
|
|
27
|
+
Handles mixed value types:
|
|
28
|
+
- Query params from parse_qs(): list[str]
|
|
29
|
+
- Path params from Starlette: str or int
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
33
|
+
self._data = data
|
|
34
|
+
|
|
35
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
36
|
+
"""Get first value for key, or default if missing."""
|
|
37
|
+
values = _as_list(self._data.get(key))
|
|
38
|
+
return values[0] if values else default
|
|
39
|
+
|
|
40
|
+
def getlist(self, key: str) -> list[str]:
|
|
41
|
+
"""Get all values for key as a list."""
|
|
42
|
+
return _as_list(self._data.get(key))
|
|
43
|
+
|
|
44
|
+
def getone(self, key: str) -> str:
|
|
45
|
+
"""Get exactly one value for key.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
KeyError: If key is missing or has multiple values.
|
|
49
|
+
"""
|
|
50
|
+
values = _as_list(self._data.get(key))
|
|
51
|
+
if len(values) != 1:
|
|
52
|
+
raise KeyError(f"Expected exactly one value for '{key}', got {len(values)}")
|
|
53
|
+
return values[0]
|
|
54
|
+
|
|
55
|
+
def has(self, key: str) -> bool:
|
|
56
|
+
"""Check if key exists."""
|
|
57
|
+
return key in self._data
|
|
58
|
+
|
|
59
|
+
def keys(self) -> list[str]:
|
|
60
|
+
"""Return all keys."""
|
|
61
|
+
return list(self._data.keys())
|
|
62
|
+
|
|
63
|
+
def items(self) -> Iterator[tuple[str, str]]:
|
|
64
|
+
"""Iterate over (key, value) pairs (first value only for each key)."""
|
|
65
|
+
for k, v in self._data.items():
|
|
66
|
+
values = _as_list(v)
|
|
67
|
+
if values:
|
|
68
|
+
yield k, values[0]
|
|
69
|
+
|
|
70
|
+
def multi_items(self) -> Iterator[tuple[str, str]]:
|
|
71
|
+
"""Iterate over all (key, value) pairs including multi-values."""
|
|
72
|
+
for k, v in self._data.items():
|
|
73
|
+
for val in _as_list(v):
|
|
74
|
+
yield k, val
|
|
75
|
+
|
|
76
|
+
def raw(self) -> dict[str, Any]:
|
|
77
|
+
"""Return the underlying dict (may contain mixed value types)."""
|
|
78
|
+
return self._data
|
|
79
|
+
|
|
80
|
+
def to_flat_dict(self) -> dict[str, Any]:
|
|
81
|
+
"""Convert to flat dict.
|
|
82
|
+
|
|
83
|
+
Single values become scalars, multiple values remain as lists.
|
|
84
|
+
"""
|
|
85
|
+
result: dict[str, Any] = {}
|
|
86
|
+
for k, v in self._data.items():
|
|
87
|
+
values = _as_list(v)
|
|
88
|
+
result[k] = values[0] if len(values) == 1 else values
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
def __contains__(self, key: str) -> bool:
|
|
92
|
+
return key in self._data
|
|
93
|
+
|
|
94
|
+
def __getitem__(self, key: str) -> Any:
|
|
95
|
+
"""Get raw value for key (returns original type from underlying dict)."""
|
|
96
|
+
return self._data[key]
|
|
97
|
+
|
|
98
|
+
def __iter__(self) -> Iterator[str]:
|
|
99
|
+
return iter(self._data)
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return len(self._data)
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str:
|
|
105
|
+
return f"Params({self._data!r})"
|
pyview/binding/result.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Result types for parameter binding."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ParamError:
|
|
9
|
+
"""Describes a parameter binding error."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
expected: str
|
|
13
|
+
value: Any
|
|
14
|
+
reason: str
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return (
|
|
18
|
+
f"Parameter '{self.name}': {self.reason} (expected {self.expected}, got {self.value!r})"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class BindResult:
|
|
24
|
+
"""Result of binding parameters to a function signature."""
|
|
25
|
+
|
|
26
|
+
bound_args: dict[str, Any]
|
|
27
|
+
errors: list[ParamError]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def success(self) -> bool:
|
|
31
|
+
"""True if binding completed without errors."""
|
|
32
|
+
return len(self.errors) == 0
|
pyview/changesets/__init__.py
CHANGED
pyview/changesets/changesets.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
from typing import TypeVar, Any, Generic, Optional
|
|
2
|
-
from pydantic import BaseModel, ValidationError
|
|
3
1
|
from dataclasses import dataclass
|
|
4
2
|
from types import SimpleNamespace
|
|
3
|
+
from typing import Any, Generic, Optional, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
5
6
|
|
|
6
7
|
Base = TypeVar("Base", bound=BaseModel)
|
|
7
8
|
|
|
@@ -52,7 +53,11 @@ class ChangeSet(Generic[Base]):
|
|
|
52
53
|
self.valid = True
|
|
53
54
|
except ValidationError as e:
|
|
54
55
|
for error in e.errors():
|
|
55
|
-
loc
|
|
56
|
+
# Model-level validators have empty loc tuple - show on current field
|
|
57
|
+
if not error["loc"]:
|
|
58
|
+
loc = k
|
|
59
|
+
else:
|
|
60
|
+
loc = str(error["loc"][0])
|
|
56
61
|
if loc in self.changes:
|
|
57
62
|
self.errors[loc] = error["msg"]
|
|
58
63
|
self.valid = False
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import click
|
|
2
|
-
from pathlib import Path
|
|
3
1
|
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
4
3
|
from typing import Optional
|
|
5
4
|
|
|
5
|
+
import click
|
|
6
|
+
|
|
6
7
|
|
|
7
8
|
def snake_case(name: str) -> str:
|
|
8
9
|
"""Convert PascalCase or camelCase to snake_case."""
|
|
@@ -50,7 +51,7 @@ def generate_html_file(name: str) -> str:
|
|
|
50
51
|
css_class = kebab_case(name)
|
|
51
52
|
return f'''<div class="{css_class}-container">
|
|
52
53
|
<h1>{pascal_case(name)}</h1>
|
|
53
|
-
|
|
54
|
+
|
|
54
55
|
<div class="content">
|
|
55
56
|
<!-- Add your content here -->
|
|
56
57
|
</div>
|
pyview/cli/main.py
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyView Components - Phoenix-style LiveComponents for Python.
|
|
3
|
+
|
|
4
|
+
This module provides stateful, reusable components that can:
|
|
5
|
+
- Maintain their own state (context)
|
|
6
|
+
- Handle their own events via phx-target
|
|
7
|
+
- Have lifecycle hooks (mount, update)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from pyview.components import LiveComponent, ComponentMeta, live_component
|
|
11
|
+
|
|
12
|
+
class Counter(LiveComponent[CounterContext]):
|
|
13
|
+
async def mount(self, socket):
|
|
14
|
+
socket.context = {"count": 0}
|
|
15
|
+
|
|
16
|
+
def template(self, assigns, meta):
|
|
17
|
+
return t'''
|
|
18
|
+
<div>
|
|
19
|
+
Count: {assigns["count"]}
|
|
20
|
+
<button phx-click="increment" phx-target="{meta.myself}">+</button>
|
|
21
|
+
</div>
|
|
22
|
+
'''
|
|
23
|
+
|
|
24
|
+
async def handle_event(self, event, payload, socket):
|
|
25
|
+
if event == "increment":
|
|
26
|
+
socket.context["count"] += 1
|
|
27
|
+
|
|
28
|
+
# In parent LiveView template:
|
|
29
|
+
{live_component(Counter, id="counter-1")}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from typing import Any, Protocol
|
|
33
|
+
|
|
34
|
+
from .base import ComponentMeta, ComponentSocket, LiveComponent
|
|
35
|
+
from .manager import ComponentsManager
|
|
36
|
+
from .slots import Slots, slots
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ComponentsManagerProtocol(Protocol):
|
|
40
|
+
"""Protocol for component manager used in text() rendering."""
|
|
41
|
+
|
|
42
|
+
def render_component(self, cid: int, parent_meta: Any) -> Any: ...
|
|
43
|
+
|
|
44
|
+
def has_pending_lifecycle(self) -> bool: ...
|
|
45
|
+
|
|
46
|
+
def get_all_cids(self) -> list[int]: ...
|
|
47
|
+
|
|
48
|
+
def get_seen_cids(self) -> set[int]: ...
|
|
49
|
+
|
|
50
|
+
async def run_pending_lifecycle(self) -> None: ...
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def component_count(self) -> int: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SocketWithComponents(Protocol):
|
|
57
|
+
"""Protocol for socket with components manager (for template rendering)."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def components(self) -> ComponentsManagerProtocol: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"LiveComponent",
|
|
65
|
+
"ComponentMeta",
|
|
66
|
+
"ComponentSocket",
|
|
67
|
+
"ComponentsManager",
|
|
68
|
+
"ComponentsManagerProtocol",
|
|
69
|
+
"SocketWithComponents",
|
|
70
|
+
"Slots",
|
|
71
|
+
"slots",
|
|
72
|
+
]
|