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.
Files changed (78) hide show
  1. pyview/__init__.py +16 -6
  2. pyview/assets/js/app.js +1 -0
  3. pyview/assets/js/uploaders.js +221 -0
  4. pyview/assets/package-lock.json +16 -14
  5. pyview/assets/package.json +2 -2
  6. pyview/async_stream_runner.py +2 -1
  7. pyview/auth/__init__.py +3 -1
  8. pyview/auth/provider.py +6 -6
  9. pyview/auth/required.py +7 -10
  10. pyview/binding/__init__.py +47 -0
  11. pyview/binding/binder.py +134 -0
  12. pyview/binding/context.py +33 -0
  13. pyview/binding/converters.py +191 -0
  14. pyview/binding/helpers.py +78 -0
  15. pyview/binding/injectables.py +119 -0
  16. pyview/binding/params.py +105 -0
  17. pyview/binding/result.py +32 -0
  18. pyview/changesets/__init__.py +2 -0
  19. pyview/changesets/changesets.py +8 -3
  20. pyview/cli/commands/create_view.py +4 -3
  21. pyview/cli/main.py +1 -1
  22. pyview/components/__init__.py +72 -0
  23. pyview/components/base.py +212 -0
  24. pyview/components/lifecycle.py +85 -0
  25. pyview/components/manager.py +366 -0
  26. pyview/components/renderer.py +14 -0
  27. pyview/components/slots.py +73 -0
  28. pyview/csrf.py +4 -2
  29. pyview/events/AutoEventDispatch.py +98 -0
  30. pyview/events/BaseEventHandler.py +51 -8
  31. pyview/events/__init__.py +2 -1
  32. pyview/instrumentation/__init__.py +3 -3
  33. pyview/instrumentation/interfaces.py +57 -33
  34. pyview/instrumentation/noop.py +21 -18
  35. pyview/js.py +20 -23
  36. pyview/live_routes.py +5 -3
  37. pyview/live_socket.py +167 -44
  38. pyview/live_view.py +24 -12
  39. pyview/meta.py +14 -2
  40. pyview/phx_message.py +7 -8
  41. pyview/playground/__init__.py +10 -0
  42. pyview/playground/builder.py +118 -0
  43. pyview/playground/favicon.py +39 -0
  44. pyview/pyview.py +54 -20
  45. pyview/session.py +2 -0
  46. pyview/static/assets/app.js +2088 -806
  47. pyview/static/assets/uploaders.js +221 -0
  48. pyview/stream.py +308 -0
  49. pyview/template/__init__.py +11 -1
  50. pyview/template/live_template.py +12 -8
  51. pyview/template/live_view_template.py +338 -0
  52. pyview/template/render_diff.py +33 -7
  53. pyview/template/root_template.py +21 -9
  54. pyview/template/serializer.py +2 -5
  55. pyview/template/template_view.py +170 -0
  56. pyview/template/utils.py +3 -2
  57. pyview/uploads.py +344 -55
  58. pyview/vendor/flet/pubsub/__init__.py +3 -1
  59. pyview/vendor/flet/pubsub/pub_sub.py +10 -18
  60. pyview/vendor/ibis/__init__.py +3 -7
  61. pyview/vendor/ibis/compiler.py +25 -32
  62. pyview/vendor/ibis/context.py +13 -15
  63. pyview/vendor/ibis/errors.py +0 -6
  64. pyview/vendor/ibis/filters.py +70 -76
  65. pyview/vendor/ibis/loaders.py +6 -7
  66. pyview/vendor/ibis/nodes.py +40 -42
  67. pyview/vendor/ibis/template.py +4 -5
  68. pyview/vendor/ibis/tree.py +62 -3
  69. pyview/vendor/ibis/utils.py +14 -15
  70. pyview/ws_handler.py +116 -86
  71. {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
  72. pyview_web-0.8.0a2.dist-info/RECORD +80 -0
  73. pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
  74. pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
  75. pyview_web-0.3.0.dist-info/LICENSE +0 -21
  76. pyview_web-0.3.0.dist-info/RECORD +0 -58
  77. pyview_web-0.3.0.dist-info/WHEEL +0 -4
  78. 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
@@ -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})"
@@ -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
@@ -1 +1,3 @@
1
1
  from .changesets import ChangeSet, change_set
2
+
3
+ __all__ = ["ChangeSet", "change_set"]
@@ -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 = str(error["loc"][0])
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
@@ -14,4 +14,4 @@ cli.add_command(create_view)
14
14
 
15
15
 
16
16
  if __name__ == "__main__":
17
- cli()
17
+ cli()
@@ -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
+ ]