pulse-framework 0.1.62__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 +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/transpiler/vdom.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Typed JSON VDOM format for transpiler.
|
|
2
|
+
|
|
3
|
+
This module defines the JSON-serializable format produced by the v2 renderer.
|
|
4
|
+
|
|
5
|
+
Key goals:
|
|
6
|
+
- The VDOM tree is rebuilt into React elements on the client.
|
|
7
|
+
- Any embedded expression tree is evaluated on the client.
|
|
8
|
+
- Only nodes representable in this JSON format are renderable.
|
|
9
|
+
|
|
10
|
+
Notes:
|
|
11
|
+
- This is a *wire format* (JSON). It is intentionally more restrictive than the
|
|
12
|
+
Python-side node graph in `pulse.transpiler.nodes`.
|
|
13
|
+
- Server-side-only nodes (e.g. `PulseNode`, `Transformer`, statement nodes) must
|
|
14
|
+
be rejected during rendering.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Literal, NotRequired, TypeAlias, TypedDict
|
|
20
|
+
|
|
21
|
+
# =============================================================================
|
|
22
|
+
# JSON atoms
|
|
23
|
+
# =============================================================================
|
|
24
|
+
|
|
25
|
+
JsonPrimitive: TypeAlias = str | int | float | bool | None
|
|
26
|
+
JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# Expression tree (client-evaluable)
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
# The expression format is a small tagged-union.
|
|
34
|
+
#
|
|
35
|
+
# - The client should treat these nodes as *pure*, deterministic expressions.
|
|
36
|
+
# - Identifiers should generally be avoided unless the client runtime provides
|
|
37
|
+
# them (e.g. `Math`). Prefer `RegistryRef` for server-provided values.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RegistryRef(TypedDict):
|
|
41
|
+
"""Reference to an entry in the unified client registry."""
|
|
42
|
+
|
|
43
|
+
t: Literal["ref"]
|
|
44
|
+
key: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class IdentifierExpr(TypedDict):
|
|
48
|
+
t: Literal["id"]
|
|
49
|
+
name: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LiteralExpr(TypedDict):
|
|
53
|
+
t: Literal["lit"]
|
|
54
|
+
value: JsonPrimitive
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UndefinedExpr(TypedDict):
|
|
58
|
+
t: Literal["undef"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ArrayExpr(TypedDict):
|
|
62
|
+
t: Literal["array"]
|
|
63
|
+
items: list["VDOMNode"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ObjectExpr(TypedDict):
|
|
67
|
+
t: Literal["object"]
|
|
68
|
+
props: dict[str, "VDOMNode"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MemberExpr(TypedDict):
|
|
72
|
+
t: Literal["member"]
|
|
73
|
+
obj: "VDOMNode"
|
|
74
|
+
prop: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SubscriptExpr(TypedDict):
|
|
78
|
+
t: Literal["sub"]
|
|
79
|
+
obj: "VDOMNode"
|
|
80
|
+
key: "VDOMNode"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CallExpr(TypedDict):
|
|
84
|
+
t: Literal["call"]
|
|
85
|
+
callee: "VDOMNode"
|
|
86
|
+
args: list["VDOMNode"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UnaryExpr(TypedDict):
|
|
90
|
+
t: Literal["unary"]
|
|
91
|
+
op: str
|
|
92
|
+
arg: "VDOMNode"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BinaryExpr(TypedDict):
|
|
96
|
+
t: Literal["binary"]
|
|
97
|
+
op: str
|
|
98
|
+
left: "VDOMNode"
|
|
99
|
+
right: "VDOMNode"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TernaryExpr(TypedDict):
|
|
103
|
+
t: Literal["ternary"]
|
|
104
|
+
cond: "VDOMNode"
|
|
105
|
+
then: "VDOMNode"
|
|
106
|
+
else_: "VDOMNode"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TemplateExpr(TypedDict):
|
|
110
|
+
"""Template literal parts.
|
|
111
|
+
|
|
112
|
+
Parts alternate: [str, expr, str, expr, str, ...].
|
|
113
|
+
Always starts and ends with a string segment (may be empty).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
t: Literal["template"]
|
|
117
|
+
parts: list["str | VDOMNode"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ArrowExpr(TypedDict):
|
|
121
|
+
t: Literal["arrow"]
|
|
122
|
+
params: list[str]
|
|
123
|
+
body: "VDOMNode"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class NewExpr(TypedDict):
|
|
127
|
+
t: Literal["new"]
|
|
128
|
+
ctor: "VDOMNode"
|
|
129
|
+
args: list["VDOMNode"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
VDOMExpr: TypeAlias = (
|
|
133
|
+
RegistryRef
|
|
134
|
+
| IdentifierExpr
|
|
135
|
+
| LiteralExpr
|
|
136
|
+
| UndefinedExpr
|
|
137
|
+
| ArrayExpr
|
|
138
|
+
| ObjectExpr
|
|
139
|
+
| MemberExpr
|
|
140
|
+
| SubscriptExpr
|
|
141
|
+
| CallExpr
|
|
142
|
+
| UnaryExpr
|
|
143
|
+
| BinaryExpr
|
|
144
|
+
| TernaryExpr
|
|
145
|
+
| TemplateExpr
|
|
146
|
+
| ArrowExpr
|
|
147
|
+
| NewExpr
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# =============================================================================
|
|
152
|
+
# VDOM tree (React element reconstruction)
|
|
153
|
+
# =============================================================================
|
|
154
|
+
|
|
155
|
+
CallbackPlaceholder: TypeAlias = Literal["$cb"]
|
|
156
|
+
"""Callback placeholder value.
|
|
157
|
+
|
|
158
|
+
The callback invocation target is derived from the element path + prop name.
|
|
159
|
+
Because the prop name is known from `VDOMElement.eval`, the placeholder can be a
|
|
160
|
+
single sentinel string.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
VDOMPropValue: TypeAlias = "JsonValue | VDOMExpr | VDOMElement | CallbackPlaceholder"
|
|
165
|
+
"""Allowed prop value types.
|
|
166
|
+
|
|
167
|
+
Hot path:
|
|
168
|
+
- If `VDOMElement.eval` is absent, props MUST be plain JSON (JsonValue).
|
|
169
|
+
- If `VDOMElement.eval` is present, only the listed prop keys may contain
|
|
170
|
+
non-JSON values (VDOMExpr / VDOMElement / "$cb:...").
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class VDOMElement(TypedDict):
|
|
175
|
+
"""A React element in wire format.
|
|
176
|
+
|
|
177
|
+
Special tags:
|
|
178
|
+
- "": React Fragment
|
|
179
|
+
- "$$<ComponentKey>": mount point for client component registry
|
|
180
|
+
- Expr object (VDOMExpr): evaluated on the client for dynamic component tags
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
tag: str | VDOMExpr
|
|
184
|
+
key: NotRequired[str]
|
|
185
|
+
# Default: plain JSON props (no interpretation).
|
|
186
|
+
# When `eval` is present, listed keys may contain VDOMExpr / VDOMElement / "$cb:...".
|
|
187
|
+
props: NotRequired[dict[str, VDOMPropValue]]
|
|
188
|
+
children: NotRequired[list["VDOMNode"]]
|
|
189
|
+
# Marks which prop keys should be interpreted (evaluate expr / render node / bind callback).
|
|
190
|
+
# The interpreter determines the action by inspecting the value shape:
|
|
191
|
+
# - dict with "t": VDOMExpr
|
|
192
|
+
# - dict with "tag": VDOMElement (render-prop subtree)
|
|
193
|
+
# - "$cb": callback placeholder
|
|
194
|
+
eval: NotRequired[list[str]]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
VDOMPrimitive: TypeAlias = JsonPrimitive
|
|
198
|
+
|
|
199
|
+
# A node is either a primitive, an element, or an expression node.
|
|
200
|
+
VDOMNode: TypeAlias = VDOMPrimitive | VDOMElement | VDOMExpr
|
|
201
|
+
|
|
202
|
+
VDOM: TypeAlias = VDOMNode
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# =============================================================================
|
|
206
|
+
# Update operations (reconciliation output)
|
|
207
|
+
# =============================================================================
|
|
208
|
+
|
|
209
|
+
RenderPath: TypeAlias = str
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ReplaceOperation(TypedDict):
|
|
213
|
+
type: Literal["replace"]
|
|
214
|
+
path: RenderPath
|
|
215
|
+
data: VDOM
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ReconciliationOperation(TypedDict):
|
|
219
|
+
type: Literal["reconciliation"]
|
|
220
|
+
path: RenderPath
|
|
221
|
+
N: int
|
|
222
|
+
new: tuple[list[int], list[VDOM]]
|
|
223
|
+
reuse: tuple[list[int], list[int]]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class UpdatePropsDelta(TypedDict, total=False):
|
|
227
|
+
# Prop deltas only affect `element.props`.
|
|
228
|
+
# If the element has `eval`, only those keys may use non-JSON values.
|
|
229
|
+
set: dict[str, VDOMPropValue]
|
|
230
|
+
remove: list[str]
|
|
231
|
+
# Optional eval key list replacement for this element.
|
|
232
|
+
# - If present, replaces `element.eval` entirely.
|
|
233
|
+
# - Use [] to clear the eval list.
|
|
234
|
+
eval: list[str]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class UpdatePropsOperation(TypedDict):
|
|
238
|
+
type: Literal["update_props"]
|
|
239
|
+
path: RenderPath
|
|
240
|
+
data: UpdatePropsDelta
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
VDOMOperation: TypeAlias = (
|
|
244
|
+
ReplaceOperation | UpdatePropsOperation | ReconciliationOperation
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# =============================================================================
|
|
249
|
+
# View payload (initial render)
|
|
250
|
+
# =============================================================================
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class PrerenderView(TypedDict):
|
|
254
|
+
"""Minimal payload required by the client to render + apply updates."""
|
|
255
|
+
|
|
256
|
+
vdom: VDOM
|
pulse/types/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
TypeVar,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
EventHandlerResult = Any
|
|
8
|
+
|
|
9
|
+
T1 = TypeVar("T1", contravariant=True)
|
|
10
|
+
T2 = TypeVar("T2", contravariant=True)
|
|
11
|
+
T3 = TypeVar("T3", contravariant=True)
|
|
12
|
+
T4 = TypeVar("T4", contravariant=True)
|
|
13
|
+
T5 = TypeVar("T5", contravariant=True)
|
|
14
|
+
T6 = TypeVar("T6", contravariant=True)
|
|
15
|
+
T7 = TypeVar("T7", contravariant=True)
|
|
16
|
+
T8 = TypeVar("T8", contravariant=True)
|
|
17
|
+
T9 = TypeVar("T9", contravariant=True)
|
|
18
|
+
T10 = TypeVar("T10", contravariant=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EventHandler0 = Callable[[], EventHandlerResult]
|
|
22
|
+
EventHandler1 = EventHandler0 | Callable[[T1], EventHandlerResult]
|
|
23
|
+
EventHandler2 = EventHandler1[T1] | Callable[[T1, T2], EventHandlerResult]
|
|
24
|
+
EventHandler3 = EventHandler2[T1, T2] | Callable[[T1, T2, T3], EventHandlerResult]
|
|
25
|
+
EventHandler4 = (
|
|
26
|
+
EventHandler3[T1, T2, T3] | Callable[[T1, T2, T3, T4], EventHandlerResult]
|
|
27
|
+
)
|
|
28
|
+
EventHandler5 = (
|
|
29
|
+
EventHandler4[T1, T2, T3, T4] | Callable[[T1, T2, T3, T4, T5], EventHandlerResult]
|
|
30
|
+
)
|
|
31
|
+
EventHandler6 = (
|
|
32
|
+
EventHandler5[T1, T2, T3, T4, T5]
|
|
33
|
+
| Callable[[T1, T2, T3, T4, T5, T6], EventHandlerResult]
|
|
34
|
+
)
|
|
35
|
+
EventHandler7 = (
|
|
36
|
+
EventHandler6[T1, T2, T3, T4, T5, T6]
|
|
37
|
+
| Callable[[T1, T2, T3, T4, T5, T6, T7], EventHandlerResult]
|
|
38
|
+
)
|
|
39
|
+
EventHandler8 = (
|
|
40
|
+
EventHandler7[T1, T2, T3, T4, T5, T6, T7]
|
|
41
|
+
| Callable[[T1, T2, T3, T4, T5, T6, T7, T8], EventHandlerResult]
|
|
42
|
+
)
|
|
43
|
+
EventHandler9 = (
|
|
44
|
+
EventHandler8[T1, T2, T3, T4, T5, T6, T7, T8]
|
|
45
|
+
| Callable[[T1, T2, T3, T4, T5, T6, T7, T8, T9], EventHandlerResult]
|
|
46
|
+
)
|
|
47
|
+
EventHandler10 = (
|
|
48
|
+
EventHandler9[T1, T2, T3, T4, T5, T6, T7, T8, T9]
|
|
49
|
+
| Callable[[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10], EventHandlerResult]
|
|
50
|
+
)
|
pulse/user_session.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import secrets
|
|
6
|
+
import uuid
|
|
7
|
+
import zlib
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast, override
|
|
10
|
+
|
|
11
|
+
from fastapi import Response
|
|
12
|
+
|
|
13
|
+
from pulse.cookies import SetCookie
|
|
14
|
+
from pulse.env import env
|
|
15
|
+
from pulse.helpers import Disposable
|
|
16
|
+
from pulse.reactive import AsyncEffect, Effect
|
|
17
|
+
from pulse.reactive_extensions import ReactiveDict, reactive, unwrap
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pulse.app import App
|
|
21
|
+
|
|
22
|
+
Session = ReactiveDict[str, Any]
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UserSession(Disposable):
|
|
28
|
+
sid: str
|
|
29
|
+
data: Session
|
|
30
|
+
app: "App"
|
|
31
|
+
is_cookie_session: bool
|
|
32
|
+
_queued_cookies: dict[str, SetCookie]
|
|
33
|
+
scheduled_cookie_refresh: bool
|
|
34
|
+
_effect: Effect | AsyncEffect
|
|
35
|
+
|
|
36
|
+
def __init__(self, sid: str, data: dict[str, Any], app: "App") -> None:
|
|
37
|
+
self.sid = sid
|
|
38
|
+
self.data = reactive(data)
|
|
39
|
+
self.scheduled_cookie_refresh = False
|
|
40
|
+
self._queued_cookies = {}
|
|
41
|
+
self.app = app
|
|
42
|
+
self.is_cookie_session = isinstance(app.session_store, CookieSessionStore)
|
|
43
|
+
if isinstance(app.session_store, CookieSessionStore):
|
|
44
|
+
self._effect = Effect(
|
|
45
|
+
lambda: self.refresh_session_cookie(app),
|
|
46
|
+
name=f"save_cookie_session:{self.sid}",
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
self._effect = AsyncEffect(
|
|
50
|
+
self._save_server_session, name=f"save_server_session:{self.sid}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def _save_server_session(self):
|
|
54
|
+
assert isinstance(self.app.session_store, SessionStore)
|
|
55
|
+
# unwrap subscribes the effect to all signals in the session ReactiveDict
|
|
56
|
+
data = unwrap(self.data)
|
|
57
|
+
await self.app.session_store.save(self.sid, data)
|
|
58
|
+
|
|
59
|
+
def refresh_session_cookie(self, app: "App"):
|
|
60
|
+
assert isinstance(app.session_store, CookieSessionStore)
|
|
61
|
+
# unwrap subscribes the effect to all signals in the session ReactiveDict
|
|
62
|
+
data = unwrap(self.data)
|
|
63
|
+
signed_cookie = app.session_store.encode(self.sid, data)
|
|
64
|
+
if app.cookie.secure is None:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"Cookie.secure is not resolved. This is likely an internal error. Ensure App.setup() ran before sessions."
|
|
67
|
+
)
|
|
68
|
+
self.set_cookie(
|
|
69
|
+
name=app.cookie.name,
|
|
70
|
+
value=signed_cookie,
|
|
71
|
+
domain=app.cookie.domain,
|
|
72
|
+
secure=app.cookie.secure,
|
|
73
|
+
samesite=app.cookie.samesite,
|
|
74
|
+
max_age_seconds=app.cookie.max_age_seconds,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
def dispose(self):
|
|
79
|
+
self._effect.dispose()
|
|
80
|
+
|
|
81
|
+
def handle_response(self, res: Response):
|
|
82
|
+
# For cookie sessions, run the effect now if it's scheduled, in order to set the updated cookie
|
|
83
|
+
if self.is_cookie_session:
|
|
84
|
+
self._effect.flush()
|
|
85
|
+
for cookie in self._queued_cookies.values():
|
|
86
|
+
cookie.set_on_fastapi(res, cookie.value)
|
|
87
|
+
self._queued_cookies.clear()
|
|
88
|
+
self.scheduled_cookie_refresh = False
|
|
89
|
+
|
|
90
|
+
def get_cookie_value(self, name: str) -> str | None:
|
|
91
|
+
cookie = self._queued_cookies.get(name)
|
|
92
|
+
if cookie is None:
|
|
93
|
+
return None
|
|
94
|
+
return cookie.value
|
|
95
|
+
|
|
96
|
+
def set_cookie(
|
|
97
|
+
self,
|
|
98
|
+
name: str,
|
|
99
|
+
value: str,
|
|
100
|
+
domain: str | None = None,
|
|
101
|
+
secure: bool = True,
|
|
102
|
+
samesite: Literal["lax", "strict", "none"] = "lax",
|
|
103
|
+
max_age_seconds: int = 7 * 24 * 3600,
|
|
104
|
+
):
|
|
105
|
+
cookie = SetCookie(
|
|
106
|
+
name=name,
|
|
107
|
+
value=value,
|
|
108
|
+
domain=domain,
|
|
109
|
+
secure=secure,
|
|
110
|
+
samesite=samesite,
|
|
111
|
+
max_age_seconds=max_age_seconds,
|
|
112
|
+
)
|
|
113
|
+
self._queued_cookies[name] = cookie
|
|
114
|
+
if not self.scheduled_cookie_refresh:
|
|
115
|
+
self.app.refresh_cookies(self.sid)
|
|
116
|
+
self.scheduled_cookie_refresh = True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SessionStore(ABC):
|
|
120
|
+
"""Abstract base class for server-backed session stores.
|
|
121
|
+
|
|
122
|
+
Implementations persist session state on the server and place only a
|
|
123
|
+
stable identifier in the cookie. Override methods to integrate with
|
|
124
|
+
your storage backend (database, cache, memory, etc.).
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
```python
|
|
128
|
+
class RedisSessionStore(SessionStore):
|
|
129
|
+
async def init(self) -> None:
|
|
130
|
+
self.redis = await aioredis.from_url("redis://localhost")
|
|
131
|
+
|
|
132
|
+
async def get(self, sid: str) -> dict[str, Any] | None:
|
|
133
|
+
data = await self.redis.get(f"session:{sid}")
|
|
134
|
+
return json.loads(data) if data else None
|
|
135
|
+
|
|
136
|
+
async def create(self, sid: str) -> dict[str, Any]:
|
|
137
|
+
session = {}
|
|
138
|
+
await self.save(sid, session)
|
|
139
|
+
return session
|
|
140
|
+
|
|
141
|
+
async def delete(self, sid: str) -> None:
|
|
142
|
+
await self.redis.delete(f"session:{sid}")
|
|
143
|
+
|
|
144
|
+
async def save(self, sid: str, session: dict[str, Any]) -> None:
|
|
145
|
+
await self.redis.set(f"session:{sid}", json.dumps(session))
|
|
146
|
+
```
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
async def init(self) -> None:
|
|
150
|
+
"""Async initialization, called on app start.
|
|
151
|
+
|
|
152
|
+
Override to establish connections or perform startup work.
|
|
153
|
+
"""
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
async def close(self) -> None:
|
|
157
|
+
"""Async cleanup, called on app shutdown.
|
|
158
|
+
|
|
159
|
+
Override to tear down connections or perform cleanup.
|
|
160
|
+
"""
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
async def get(self, sid: str) -> dict[str, Any] | None:
|
|
165
|
+
"""Retrieve session by ID.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
sid: Session identifier.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Session data dict if found, None otherwise.
|
|
172
|
+
"""
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
@abstractmethod
|
|
176
|
+
async def create(self, sid: str) -> dict[str, Any]:
|
|
177
|
+
"""Create a new session.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
sid: Session identifier.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
New empty session dict.
|
|
184
|
+
"""
|
|
185
|
+
...
|
|
186
|
+
|
|
187
|
+
@abstractmethod
|
|
188
|
+
async def delete(self, sid: str) -> None:
|
|
189
|
+
"""Delete a session.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
sid: Session identifier.
|
|
193
|
+
"""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
async def save(self, sid: str, session: dict[str, Any]) -> None:
|
|
198
|
+
"""Persist session data.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
sid: Session identifier.
|
|
202
|
+
session: Session data to persist.
|
|
203
|
+
"""
|
|
204
|
+
...
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class InMemorySessionStore(SessionStore):
|
|
208
|
+
"""In-memory session store implementation.
|
|
209
|
+
|
|
210
|
+
Sessions are stored in memory and lost on restart. Suitable for
|
|
211
|
+
development and testing.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
```python
|
|
215
|
+
store = ps.InMemorySessionStore()
|
|
216
|
+
app = ps.App(session_store=store)
|
|
217
|
+
```
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self) -> None:
|
|
221
|
+
self._sessions: dict[str, dict[str, Any]] = {}
|
|
222
|
+
|
|
223
|
+
@override
|
|
224
|
+
async def get(self, sid: str) -> dict[str, Any] | None:
|
|
225
|
+
return self._sessions.get(sid)
|
|
226
|
+
|
|
227
|
+
@override
|
|
228
|
+
async def create(self, sid: str) -> dict[str, Any]:
|
|
229
|
+
session: Session = ReactiveDict()
|
|
230
|
+
self._sessions[sid] = session
|
|
231
|
+
return session
|
|
232
|
+
|
|
233
|
+
@override
|
|
234
|
+
async def save(self, sid: str, session: dict[str, Any]) -> None:
|
|
235
|
+
# Should not matter as the session ReactiveDict is normally mutated directly
|
|
236
|
+
self._sessions[sid] = session
|
|
237
|
+
|
|
238
|
+
@override
|
|
239
|
+
async def delete(self, sid: str) -> None:
|
|
240
|
+
_ = self._sessions.pop(sid, None)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SessionCookiePayload(TypedDict):
|
|
244
|
+
sid: str
|
|
245
|
+
data: dict[str, Any]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class CookieSessionStore:
|
|
249
|
+
"""Store sessions in signed cookies. Default session store.
|
|
250
|
+
|
|
251
|
+
The cookie stores a compact JSON of the session signed with HMAC-SHA256
|
|
252
|
+
to prevent tampering. Keep session data small (<4KB).
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
secret: Signing secret. Uses PULSE_SECRET env var if not provided.
|
|
256
|
+
Required in production.
|
|
257
|
+
salt: Salt for HMAC. Default: "pulse.session".
|
|
258
|
+
digestmod: Hash algorithm. Default: "sha256".
|
|
259
|
+
max_cookie_bytes: Maximum cookie size. Default: 3800.
|
|
260
|
+
|
|
261
|
+
Environment Variables:
|
|
262
|
+
PULSE_SECRET: Session signing secret (required in production).
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
```python
|
|
266
|
+
# Uses PULSE_SECRET environment variable
|
|
267
|
+
store = ps.CookieSessionStore()
|
|
268
|
+
|
|
269
|
+
# Explicit secret
|
|
270
|
+
store = ps.CookieSessionStore(secret="your-secret-key")
|
|
271
|
+
|
|
272
|
+
app = ps.App(session_store=store)
|
|
273
|
+
```
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
digestmod: str
|
|
277
|
+
secret: bytes
|
|
278
|
+
salt: bytes
|
|
279
|
+
max_cookie_bytes: int
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
secret: str | None = None,
|
|
284
|
+
*,
|
|
285
|
+
salt: str = "pulse.session",
|
|
286
|
+
digestmod: str = "sha256",
|
|
287
|
+
max_cookie_bytes: int = 3800,
|
|
288
|
+
) -> None:
|
|
289
|
+
if not secret:
|
|
290
|
+
secret = env.pulse_secret or ""
|
|
291
|
+
if not secret:
|
|
292
|
+
pulse_env = env.pulse_env
|
|
293
|
+
if pulse_env == "prod":
|
|
294
|
+
# In CI/production, require an explicit secret
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
"PULSE_SECRET must be set when using CookieSessionStore in production.\nCookieSessionStore is the default way of storing sessions in Pulse. Providing a secret is necessary to not invalidate all sessions on reload."
|
|
297
|
+
)
|
|
298
|
+
# In dev, use an ephemeral secret silently
|
|
299
|
+
secret = secrets.token_urlsafe(32)
|
|
300
|
+
self.secret = secret.encode("utf-8")
|
|
301
|
+
self.salt = salt.encode("utf-8")
|
|
302
|
+
self.digestmod = digestmod
|
|
303
|
+
self.max_cookie_bytes = max_cookie_bytes
|
|
304
|
+
|
|
305
|
+
def encode(self, sid: str, session: dict[str, Any]) -> str:
|
|
306
|
+
"""Encode session to signed cookie value.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
sid: Session identifier.
|
|
310
|
+
session: Session data to encode.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Signed cookie value string.
|
|
314
|
+
"""
|
|
315
|
+
# Encode the entire session into the cookie (compressed v1)
|
|
316
|
+
try:
|
|
317
|
+
data = SessionCookiePayload(sid=sid, data=dict(session))
|
|
318
|
+
payload_json = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
319
|
+
compressed = zlib.compress(payload_json, level=6)
|
|
320
|
+
signed = self._sign(compressed)
|
|
321
|
+
if len(signed) > self.max_cookie_bytes:
|
|
322
|
+
logging.warning("Session cookie too large, truncating")
|
|
323
|
+
session.clear()
|
|
324
|
+
return self.encode(sid, session)
|
|
325
|
+
return signed
|
|
326
|
+
except Exception:
|
|
327
|
+
logging.warning("Error encoding session cookie, truncating")
|
|
328
|
+
session.clear()
|
|
329
|
+
return self.encode(sid, session)
|
|
330
|
+
|
|
331
|
+
def decode(self, cookie: str) -> tuple[str, Session] | None:
|
|
332
|
+
"""Decode and verify signed cookie.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
cookie: Signed cookie value string.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Tuple of (sid, session) if valid, None if invalid or tampered.
|
|
339
|
+
"""
|
|
340
|
+
if not cookie:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
raw = self._unsign(cookie)
|
|
344
|
+
if raw is None:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
payload_json = zlib.decompress(raw).decode("utf-8")
|
|
349
|
+
data = cast(SessionCookiePayload, json.loads(payload_json))
|
|
350
|
+
return data["sid"], ReactiveDict(data["data"])
|
|
351
|
+
except Exception:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
# --- signing helpers ---
|
|
355
|
+
def _mac(self, payload: bytes) -> bytes:
|
|
356
|
+
return hmac.new(
|
|
357
|
+
self.secret + b"|" + self.salt, payload, self.digestmod
|
|
358
|
+
).digest()
|
|
359
|
+
|
|
360
|
+
def _sign(self, payload: bytes) -> str:
|
|
361
|
+
mac = self._mac(payload)
|
|
362
|
+
b64 = base64.urlsafe_b64encode(payload).rstrip(b"=")
|
|
363
|
+
sig = base64.urlsafe_b64encode(mac).rstrip(b"=")
|
|
364
|
+
return f"v1.{b64.decode('ascii')}.{sig.decode('ascii')}"
|
|
365
|
+
|
|
366
|
+
def _unsign(self, token: str) -> bytes | None:
|
|
367
|
+
try:
|
|
368
|
+
if not token.startswith("v1."):
|
|
369
|
+
return None
|
|
370
|
+
_, b64, sig = token.split(".", 2)
|
|
371
|
+
|
|
372
|
+
def _pad(s: str) -> bytes:
|
|
373
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
374
|
+
|
|
375
|
+
raw = _pad(b64)
|
|
376
|
+
mac = _pad(sig)
|
|
377
|
+
expected = self._mac(raw)
|
|
378
|
+
if not hmac.compare_digest(mac, expected):
|
|
379
|
+
return None
|
|
380
|
+
return raw
|
|
381
|
+
except Exception:
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def new_sid() -> str:
|
|
386
|
+
return uuid.uuid4().hex
|