pulse-framework 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.
Files changed (50) hide show
  1. pulse/__init__.py +175 -0
  2. pulse/app.py +349 -0
  3. pulse/cmd.py +324 -0
  4. pulse/codegen.py +147 -0
  5. pulse/components/__init__.py +1 -0
  6. pulse/components/react_router.py +43 -0
  7. pulse/context.py +15 -0
  8. pulse/decorators.py +187 -0
  9. pulse/diff.py +252 -0
  10. pulse/flags.py +5 -0
  11. pulse/flatted.py +159 -0
  12. pulse/helpers.py +27 -0
  13. pulse/hooks.py +441 -0
  14. pulse/html/__init__.py +304 -0
  15. pulse/html/attributes.py +930 -0
  16. pulse/html/elements.py +1024 -0
  17. pulse/html/events.py +419 -0
  18. pulse/html/tags.py +171 -0
  19. pulse/html/tags.pyi +390 -0
  20. pulse/messages.py +109 -0
  21. pulse/middleware.py +158 -0
  22. pulse/query.py +286 -0
  23. pulse/react_component.py +803 -0
  24. pulse/reactive.py +514 -0
  25. pulse/reactive_extensions.py +626 -0
  26. pulse/reconciler.py +575 -0
  27. pulse/request.py +162 -0
  28. pulse/routing.py +350 -0
  29. pulse/session.py +310 -0
  30. pulse/state.py +309 -0
  31. pulse/templates.py +171 -0
  32. pulse/tests/__init__.py +0 -0
  33. pulse/tests/old_test_diff.py +174 -0
  34. pulse/tests/test_codegen.py +224 -0
  35. pulse/tests/test_flatted.py +297 -0
  36. pulse/tests/test_nodes.py +439 -0
  37. pulse/tests/test_query.py +391 -0
  38. pulse/tests/test_react.py +797 -0
  39. pulse/tests/test_reactive.py +1203 -0
  40. pulse/tests/test_reconciler.py +1759 -0
  41. pulse/tests/test_routing.py +167 -0
  42. pulse/tests/test_session.py +267 -0
  43. pulse/tests/test_state.py +569 -0
  44. pulse/tests/test_utils.py +101 -0
  45. pulse/vdom.py +381 -0
  46. pulse_framework-0.1.0.dist-info/METADATA +38 -0
  47. pulse_framework-0.1.0.dist-info/RECORD +50 -0
  48. pulse_framework-0.1.0.dist-info/WHEEL +4 -0
  49. pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
  50. pulse_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
pulse/decorators.py ADDED
@@ -0,0 +1,187 @@
1
+ # Separate file from reactive.py due to needing to import from state too
2
+
3
+ from typing import Any, Callable, Coroutine, Optional, TypeVar, overload
4
+
5
+ from pulse.state import State, ComputedProperty, StateEffect
6
+ from pulse.reactive import Computed, Effect, EffectCleanup, EffectFn
7
+ import inspect
8
+ from pulse.query import QueryProperty, QueryPropertyWithInitial
9
+
10
+
11
+ T = TypeVar("T")
12
+ TState = TypeVar("TState", bound=State)
13
+
14
+
15
+ # -> @ps.computed The chalenge is:
16
+ # - We want to turn regular functions with no arguments into a Computed object
17
+ # - We want to turn state methods into a ComputedProperty (which wraps a
18
+ # Computed, but gives it access to the State object).
19
+ @overload
20
+ def computed(fn: Callable[[], T], *, name: Optional[str] = None) -> Computed[T]: ...
21
+ @overload
22
+ def computed(
23
+ fn: Callable[[TState], T], *, name: Optional[str] = None
24
+ ) -> ComputedProperty[T]: ...
25
+ @overload
26
+ def computed(
27
+ fn: None = None, *, name: Optional[str] = None
28
+ ) -> Callable[[Callable[[], T]], Computed[T]]: ...
29
+
30
+
31
+ def computed(fn: Optional[Callable] = None, *, name: Optional[str] = None):
32
+ # The type checker is not happy if I don't specify the `/` here.
33
+ def decorator(fn: Callable, /):
34
+ sig = inspect.signature(fn)
35
+ params = list(sig.parameters.values())
36
+ # Check if it's a method with exactly one argument called 'self'
37
+ if len(params) == 1 and params[0].name == "self":
38
+ return ComputedProperty(fn.__name__, fn)
39
+ # If it has any arguments at all, it's not allowed (except for 'self')
40
+ if len(params) > 0:
41
+ raise TypeError(
42
+ f"@computed: Function '{fn.__name__}' must take no arguments or a single 'self' argument"
43
+ )
44
+ return Computed(fn, name=name or fn.__name__)
45
+
46
+ if fn is not None:
47
+ return decorator(fn)
48
+ else:
49
+ return decorator
50
+
51
+
52
+ @overload
53
+ def effect(
54
+ fn: EffectFn,
55
+ *,
56
+ name: Optional[str] = None,
57
+ immediate: bool = False,
58
+ lazy: bool = False,
59
+ on_error: Optional[Callable[[Exception], None]] = None,
60
+ ) -> Effect: ...
61
+ # In practice this overload returns a StateEffect, but it gets converted into an
62
+ # Effect at state instantiation.
63
+ @overload
64
+ def effect(
65
+ fn: Callable[[TState], None] | Callable[[TState], EffectCleanup],
66
+ ) -> Effect: ...
67
+ @overload
68
+ def effect(
69
+ fn: None = None,
70
+ *,
71
+ name: Optional[str] = None,
72
+ immediate: bool = False,
73
+ lazy: bool = False,
74
+ on_error: Optional[Callable[[Exception], None]] = None,
75
+ ) -> Callable[[EffectFn], Effect]: ...
76
+
77
+
78
+ def effect(
79
+ fn: Optional[Callable] = None,
80
+ *,
81
+ name: Optional[str] = None,
82
+ immediate: bool = False,
83
+ lazy: bool = False,
84
+ on_error: Optional[Callable[[Exception], None]] = None,
85
+ ):
86
+ # The type checker is not happy if I don't specify the `/` here.
87
+ def decorator(func: Callable, /):
88
+ sig = inspect.signature(func)
89
+ params = list(sig.parameters.values())
90
+
91
+ if len(params) == 1 and params[0].name == "self":
92
+ return StateEffect(func, on_error=on_error)
93
+
94
+ if len(params) > 0:
95
+ raise TypeError(
96
+ f"@effect: Function '{func.__name__}' must take no arguments or a single 'self' argument"
97
+ )
98
+
99
+ # This is a standalone effect function. Create the Effect object.
100
+ return Effect(
101
+ func,
102
+ name=name or func.__name__,
103
+ immediate=immediate,
104
+ lazy=lazy,
105
+ on_error=on_error,
106
+ )
107
+
108
+ if fn:
109
+ return decorator(fn)
110
+ return decorator
111
+
112
+
113
+ # -----------------
114
+ # Query decorator
115
+ # -----------------
116
+ @overload
117
+ def query(
118
+ fn: Callable[[TState], Coroutine[Any, Any, T]],
119
+ *,
120
+ keep_alive: bool = False, # noqa: F821
121
+ keep_previous_data: bool = True,
122
+ ) -> QueryProperty[T]: ...
123
+ @overload
124
+ def query(
125
+ fn: None = None, *, keep_alive: bool = False, keep_previous_data: bool = True
126
+ ) -> Callable[[Callable[[TState], Coroutine[Any, Any, T]]], QueryProperty[T]]: ...
127
+
128
+
129
+ # When an initial value is provided, the resulting property narrows data to non-None
130
+ @overload
131
+ def query(
132
+ fn: Callable[[TState], Coroutine[Any, Any, T]],
133
+ *,
134
+ keep_alive: bool = False,
135
+ keep_previous_data: bool = True,
136
+ initial: T,
137
+ ) -> QueryPropertyWithInitial[T]: ...
138
+ @overload
139
+ def query(
140
+ fn: None = None,
141
+ *,
142
+ keep_alive: bool = False,
143
+ keep_previous_data: bool = True,
144
+ initial: T,
145
+ ) -> Callable[
146
+ [Callable[[TState], Coroutine[Any, Any, T]]], QueryPropertyWithInitial[T]
147
+ ]: ...
148
+
149
+
150
+ def query(
151
+ fn: Optional[Callable] = None,
152
+ *,
153
+ keep_alive: bool = False,
154
+ keep_previous_data: bool = True,
155
+ initial: Any = None,
156
+ ) -> (
157
+ QueryProperty[T]
158
+ | QueryPropertyWithInitial[T]
159
+ | Callable[
160
+ [Callable[[TState], Coroutine[Any, Any, T]]],
161
+ QueryProperty[T] | QueryPropertyWithInitial[T],
162
+ ]
163
+ ):
164
+ def decorator(func: Callable[[TState], Coroutine[Any, Any, T]], /):
165
+ sig = inspect.signature(func)
166
+ params = list(sig.parameters.values())
167
+ # Only state-method form supported for now (single 'self')
168
+ if not (len(params) == 1 and params[0].name == "self"):
169
+ raise TypeError("@query currently only supports state methods (self)")
170
+ if initial is not None:
171
+ return QueryPropertyWithInitial(
172
+ func.__name__,
173
+ func,
174
+ keep_alive=keep_alive,
175
+ keep_previous_data=keep_previous_data,
176
+ initial=initial,
177
+ )
178
+ return QueryProperty(
179
+ func.__name__,
180
+ func,
181
+ keep_alive=keep_alive,
182
+ keep_previous_data=keep_previous_data,
183
+ )
184
+
185
+ if fn:
186
+ return decorator(fn)
187
+ return decorator
pulse/diff.py ADDED
@@ -0,0 +1,252 @@
1
+ from typing import (
2
+ TypedDict,
3
+ Union,
4
+ Optional,
5
+ Literal,
6
+ Sequence,
7
+ Any,
8
+ )
9
+ from .vdom import VDOM, Props, Node, Primitive, Element
10
+
11
+
12
+ class InsertOperation(TypedDict):
13
+ type: Literal["insert"]
14
+ path: str
15
+ data: VDOM
16
+
17
+
18
+ class RemoveOperation(TypedDict):
19
+ type: Literal["remove"]
20
+ path: str
21
+
22
+
23
+ class ReplaceOperation(TypedDict):
24
+ type: Literal["replace"]
25
+ path: str
26
+ data: VDOM
27
+
28
+
29
+ class UpdatePropsOperation(TypedDict):
30
+ type: Literal["update_props"]
31
+ path: str
32
+ data: Props
33
+
34
+
35
+ class MoveOperationData(TypedDict):
36
+ from_index: int
37
+ to_index: int
38
+ key: str
39
+
40
+
41
+ class MoveOperation(TypedDict):
42
+ type: Literal["move"]
43
+ path: str
44
+ data: MoveOperationData
45
+
46
+
47
+ VDOMOperation = Union[
48
+ InsertOperation,
49
+ RemoveOperation,
50
+ ReplaceOperation,
51
+ UpdatePropsOperation,
52
+ MoveOperation,
53
+ ]
54
+
55
+
56
+ def _render_to_vdom(node: Union[Node, Primitive], path: str) -> VDOM:
57
+ if isinstance(node, Node):
58
+ return node._render_node(path, {})
59
+ return node
60
+
61
+
62
+ def _effective_props(node: Node, path: str) -> dict[str, Any]:
63
+ props = dict(node.props or {})
64
+ if node.callbacks:
65
+ path_prefix = (path + ".") if path else ""
66
+ for cb_name in node.callbacks.keys():
67
+ props[cb_name] = f"$$fn:{path_prefix}{cb_name}"
68
+ return props
69
+
70
+
71
+ def diff_node(
72
+ old_node: Optional[Union[Node, Primitive]],
73
+ new_node: Optional[Union[Node, Primitive]],
74
+ path: str = "",
75
+ ) -> list[VDOMOperation]:
76
+ operations: list[VDOMOperation] = []
77
+
78
+ # Handle null cases
79
+ if old_node is None and new_node is None:
80
+ return operations
81
+ elif old_node is None:
82
+ assert new_node is not None
83
+ operations.append(
84
+ {"type": "insert", "path": path, "data": _render_to_vdom(new_node, path)}
85
+ )
86
+ return operations
87
+ elif new_node is None:
88
+ operations.append({"type": "remove", "path": path, "data": None})
89
+ return operations
90
+
91
+ # Primitives equality
92
+ if not isinstance(old_node, Node) and not isinstance(new_node, Node):
93
+ if old_node == new_node:
94
+ return operations
95
+ else:
96
+ operations.append({"type": "replace", "path": path, "data": new_node})
97
+ return operations
98
+
99
+ # Type mismatch or tag difference => replace
100
+ if not isinstance(old_node, Node) or not isinstance(new_node, Node):
101
+ operations.append(
102
+ {"type": "replace", "path": path, "data": _render_to_vdom(new_node, path)}
103
+ )
104
+ return operations
105
+
106
+ if old_node.tag != new_node.tag:
107
+ operations.append(
108
+ {"type": "replace", "path": path, "data": _render_to_vdom(new_node, path)}
109
+ )
110
+ return operations
111
+
112
+ # Same tag - diff props (including callback placeholders)
113
+ old_props = _effective_props(old_node, path)
114
+ new_props = _effective_props(new_node, path)
115
+ if old_props != new_props:
116
+ operations.append({"type": "update_props", "path": path, "data": new_props})
117
+
118
+ # Diff children
119
+ old_children: list[Element] = list(old_node.children or [])
120
+ new_children: list[Element] = list(new_node.children or [])
121
+
122
+ # Determine strategy based on keys
123
+ has_keyed_old = any(isinstance(c, Node) and c.key is not None for c in old_children)
124
+ has_keyed_new = any(isinstance(c, Node) and c.key is not None for c in new_children)
125
+
126
+ if has_keyed_old or has_keyed_new:
127
+ operations.extend(_diff_keyed_children(old_children, new_children, path))
128
+ else:
129
+ operations.extend(_diff_positional_children(old_children, new_children, path))
130
+
131
+ return operations
132
+
133
+
134
+ def _diff_keyed_children(
135
+ old_children: Sequence[Element], new_children: Sequence[Element], path: str
136
+ ) -> list[VDOMOperation]:
137
+ operations: list[VDOMOperation] = []
138
+
139
+ old_keyed: dict[str, Node] = {}
140
+ old_positions: dict[str, int] = {}
141
+ new_keyed: dict[str, Node] = {}
142
+
143
+ for i, child in enumerate(old_children):
144
+ if isinstance(child, Node) and child.key is not None:
145
+ old_keyed[child.key] = child
146
+ old_positions[child.key] = i
147
+
148
+ for i, child in enumerate(new_children):
149
+ if isinstance(child, Node) and child.key is not None:
150
+ new_keyed[child.key] = child
151
+
152
+ used_old_positions: set[int] = set()
153
+
154
+ for new_index, new_child in enumerate(new_children):
155
+ child_path = f"{path}.{new_index}" if path else str(new_index)
156
+
157
+ if isinstance(new_child, Node) and new_child.key is not None:
158
+ key = new_child.key
159
+ if key in old_keyed:
160
+ old_child = old_keyed[key]
161
+ old_index = old_positions[key]
162
+ if old_index != new_index:
163
+ operations.append(
164
+ {
165
+ "type": "move",
166
+ "path": child_path,
167
+ "data": {
168
+ "from_index": old_index,
169
+ "to_index": new_index,
170
+ "key": key,
171
+ },
172
+ }
173
+ )
174
+ used_old_positions.add(old_index)
175
+ operations.extend(diff_node(old_child, new_child, child_path))
176
+ else:
177
+ operations.append(
178
+ {
179
+ "type": "insert",
180
+ "path": child_path,
181
+ "data": _render_to_vdom(new_child, child_path),
182
+ }
183
+ )
184
+ else:
185
+ # Unkeyed new element - try positional match
186
+ old_child_at_pos = (
187
+ old_children[new_index] if new_index < len(old_children) else None
188
+ )
189
+ if (
190
+ new_index < len(old_children)
191
+ and new_index not in used_old_positions
192
+ and not (
193
+ isinstance(old_child_at_pos, Node)
194
+ and old_child_at_pos.key is not None
195
+ )
196
+ ):
197
+ used_old_positions.add(new_index)
198
+ operations.extend(
199
+ diff_node(old_children[new_index], new_child, child_path)
200
+ )
201
+ else:
202
+ operations.append(
203
+ {
204
+ "type": "insert",
205
+ "path": child_path,
206
+ "data": _render_to_vdom(new_child, child_path),
207
+ }
208
+ )
209
+
210
+ # Remove old keyed elements that disappeared
211
+ for key, old_child in old_keyed.items():
212
+ if key not in new_keyed:
213
+ old_index = old_positions[key]
214
+ old_child_path = f"{path}.{old_index}" if path else str(old_index)
215
+ operations.append({"type": "remove", "path": old_child_path, "data": key})
216
+
217
+ # Remove leftover unkeyed olds
218
+ for old_index, old_child in enumerate(old_children):
219
+ if old_index not in used_old_positions and not (
220
+ isinstance(old_child, Node) and old_child.key is not None
221
+ ):
222
+ old_child_path = f"{path}.{old_index}" if path else str(old_index)
223
+ operations.append({"type": "remove", "path": old_child_path, "data": None})
224
+
225
+ return operations
226
+
227
+
228
+ def _diff_positional_children(
229
+ old_children: Sequence[Element], new_children: Sequence[Element], path: str
230
+ ) -> list[VDOMOperation]:
231
+ operations: list[VDOMOperation] = []
232
+ max_len = max(len(old_children), len(new_children))
233
+
234
+ for i in range(max_len):
235
+ child_path = f"{path}.{i}" if path else str(i)
236
+ old_child = old_children[i] if i < len(old_children) else None
237
+ new_child = new_children[i] if i < len(new_children) else None
238
+
239
+ if old_child is not None and new_child is not None:
240
+ operations.extend(diff_node(old_child, new_child, child_path))
241
+ elif new_child is not None:
242
+ operations.append(
243
+ {
244
+ "type": "insert",
245
+ "path": child_path,
246
+ "data": _render_to_vdom(new_child, child_path),
247
+ }
248
+ )
249
+ else:
250
+ operations.append({"type": "remove", "path": child_path, "data": None})
251
+
252
+ return operations
pulse/flags.py ADDED
@@ -0,0 +1,5 @@
1
+
2
+ from contextvars import ContextVar
3
+
4
+
5
+ IS_PRERENDERING: ContextVar[bool] = ContextVar("pulse_is_prerendering", default=False)
pulse/flatted.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ Simple object transformation for socket.io transport
3
+ Handles Dates, circular references, and basic Python objects
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import Any, Dict
8
+
9
+
10
+ def stringify(input_value: Any) -> Any:
11
+ """
12
+ Convert Python objects into a serializable format that handles circular references.
13
+
14
+ Args:
15
+ input_value: The object to serialize
16
+
17
+ Returns:
18
+ A JSON-serializable object with special markers for complex types
19
+ """
20
+ seen: Dict[int, int] = {} # Map object id to assigned ID
21
+ next_id = 1
22
+
23
+ def transform(value: Any) -> Any:
24
+ nonlocal next_id
25
+
26
+ # Handle primitives
27
+ if value is None or isinstance(value, (int, float, str, bool)):
28
+ return value
29
+
30
+ # Handle objects that can have circular references
31
+ if isinstance(value, (dict, list, datetime)) or hasattr(value, "__dict__"):
32
+ obj_id = id(value)
33
+ if obj_id in seen:
34
+ return {"__ref": seen[obj_id]}
35
+
36
+ # Special type transformations
37
+ if isinstance(value, datetime):
38
+ current_id = next_id
39
+ next_id += 1
40
+ seen[id(value)] = current_id
41
+ # Type checker safety: we know value is datetime.datetime here
42
+ dt_value: datetime.datetime = value # type: ignore
43
+ return {
44
+ "__pulse": "date",
45
+ "__id": current_id,
46
+ "timestamp": int(
47
+ dt_value.timestamp() * 1000
48
+ ), # Convert to milliseconds
49
+ }
50
+
51
+ # Handle lists
52
+ if isinstance(value, list):
53
+ current_id = next_id
54
+ next_id += 1
55
+ seen[id(value)] = current_id
56
+ return {
57
+ "__pulse": "array",
58
+ "__id": current_id,
59
+ "items": [transform(item) for item in value],
60
+ }
61
+
62
+ # Handle dictionaries
63
+ if isinstance(value, dict):
64
+ current_id = next_id
65
+ next_id += 1
66
+ seen[id(value)] = current_id
67
+
68
+ user_data = {}
69
+ for key, val in value.items():
70
+ if callable(val):
71
+ continue # Skip functions/methods
72
+ user_data[str(key)] = transform(val)
73
+
74
+ return {"__pulse": "object", "__id": current_id, "__data": user_data}
75
+
76
+ # Handle custom objects with __dict__
77
+ if hasattr(value, "__dict__"):
78
+ current_id = next_id
79
+ next_id += 1
80
+ seen[id(value)] = current_id
81
+
82
+ user_data = {}
83
+ for key, val in value.__dict__.items():
84
+ if callable(val) or key.startswith("_"):
85
+ continue # Skip private attributes and methods
86
+ user_data[str(key)] = transform(val)
87
+
88
+ return {"__pulse": "object", "__id": current_id, "__data": user_data}
89
+
90
+ # Fallback for unknown types - convert to string
91
+ return str(value)
92
+
93
+ return transform(input_value)
94
+
95
+
96
+ def parse(input_value: Any) -> Any:
97
+ """
98
+ Parse serialized objects back into Python objects, resolving circular references.
99
+
100
+ Args:
101
+ input_value: The serialized object to parse
102
+
103
+ Returns:
104
+ The reconstructed Python object
105
+ """
106
+ objects: Dict[int, Any] = {}
107
+
108
+ def resolve(value: Any) -> Any:
109
+ if value is None or isinstance(value, (int, float, str, bool)):
110
+ return value
111
+
112
+ if isinstance(value, list):
113
+ return [resolve(item) for item in value]
114
+
115
+ if not isinstance(value, dict):
116
+ return value
117
+
118
+ obj = value
119
+
120
+ # Handle references
121
+ if "__ref" in obj:
122
+ ref_id = obj["__ref"]
123
+ return objects.get(ref_id)
124
+
125
+ # Handle special types (only if they have __pulse marker)
126
+ if obj.get("__pulse") == "date":
127
+ obj_id = obj["__id"]
128
+ timestamp_ms = obj["timestamp"]
129
+ resolved = datetime.fromtimestamp(timestamp_ms / 1000.0)
130
+ objects[obj_id] = resolved
131
+ return resolved
132
+
133
+ if obj.get("__pulse") == "array":
134
+ obj_id = obj["__id"]
135
+ resolved = []
136
+ objects[obj_id] = resolved
137
+
138
+ items = obj["items"]
139
+ for item in items:
140
+ resolved.append(resolve(item))
141
+ return resolved
142
+
143
+ if obj.get("__pulse") == "object":
144
+ obj_id = obj["__id"]
145
+ resolved = {}
146
+ objects[obj_id] = resolved
147
+
148
+ user_data = obj["__data"]
149
+ for key, val in user_data.items():
150
+ resolved[key] = resolve(val)
151
+ return resolved
152
+
153
+ # Unknown object type - process properties
154
+ result = {}
155
+ for key, val in obj.items():
156
+ result[key] = resolve(val)
157
+ return result
158
+
159
+ return resolve(input_value)
pulse/helpers.py ADDED
@@ -0,0 +1,27 @@
1
+ from typing import Any, Callable, Coroutine, Iterable, TypeVar, TypeVarTuple, Unpack
2
+
3
+ from pulse.vdom import Element
4
+
5
+
6
+ Args = TypeVarTuple("Args")
7
+ EventHandler = (
8
+ Callable[[], None]
9
+ | Callable[[], Coroutine[Any, Any, None]]
10
+ | Callable[[Unpack[Args]], None]
11
+ | Callable[[Unpack[Args]], Coroutine[Any, Any, None]]
12
+ )
13
+
14
+
15
+ class Sentinel:
16
+ def __init__(self, name: str) -> None:
17
+ self.name = name
18
+
19
+ def __repr__(self) -> str:
20
+ return self.name
21
+
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ def For(items: Iterable[T], fn: Callable[[T], Element]):
27
+ return [fn(item) for item in items]