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.
- pulse/__init__.py +175 -0
- pulse/app.py +349 -0
- pulse/cmd.py +324 -0
- pulse/codegen.py +147 -0
- pulse/components/__init__.py +1 -0
- pulse/components/react_router.py +43 -0
- pulse/context.py +15 -0
- pulse/decorators.py +187 -0
- pulse/diff.py +252 -0
- pulse/flags.py +5 -0
- pulse/flatted.py +159 -0
- pulse/helpers.py +27 -0
- pulse/hooks.py +441 -0
- pulse/html/__init__.py +304 -0
- pulse/html/attributes.py +930 -0
- pulse/html/elements.py +1024 -0
- pulse/html/events.py +419 -0
- pulse/html/tags.py +171 -0
- pulse/html/tags.pyi +390 -0
- pulse/messages.py +109 -0
- pulse/middleware.py +158 -0
- pulse/query.py +286 -0
- pulse/react_component.py +803 -0
- pulse/reactive.py +514 -0
- pulse/reactive_extensions.py +626 -0
- pulse/reconciler.py +575 -0
- pulse/request.py +162 -0
- pulse/routing.py +350 -0
- pulse/session.py +310 -0
- pulse/state.py +309 -0
- pulse/templates.py +171 -0
- pulse/tests/__init__.py +0 -0
- pulse/tests/old_test_diff.py +174 -0
- pulse/tests/test_codegen.py +224 -0
- pulse/tests/test_flatted.py +297 -0
- pulse/tests/test_nodes.py +439 -0
- pulse/tests/test_query.py +391 -0
- pulse/tests/test_react.py +797 -0
- pulse/tests/test_reactive.py +1203 -0
- pulse/tests/test_reconciler.py +1759 -0
- pulse/tests/test_routing.py +167 -0
- pulse/tests/test_session.py +267 -0
- pulse/tests/test_state.py +569 -0
- pulse/tests/test_utils.py +101 -0
- pulse/vdom.py +381 -0
- pulse_framework-0.1.0.dist-info/METADATA +38 -0
- pulse_framework-0.1.0.dist-info/RECORD +50 -0
- pulse_framework-0.1.0.dist-info/WHEEL +4 -0
- pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
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]
|