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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -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
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