pulse-framework 0.1.53__py3-none-any.whl → 0.1.54__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/js/obj.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ JavaScript object literal creation.
3
+
4
+ Usage:
5
+ from pulse.js import obj
6
+
7
+ # Create plain JS objects (not Maps):
8
+ obj(a=1, b=2) # -> { a: 1, b: 2 }
9
+
10
+ # With spread syntax:
11
+ obj(**base, c=3) # -> { ...base, c: 3 }
12
+ obj(a=1, **base) # -> { a: 1, ...base }
13
+
14
+ # Empty object:
15
+ obj() # -> {}
16
+
17
+ Unlike dict() which transpiles to new Map(), obj() creates plain JavaScript
18
+ object literals. Use this for React props, style objects, and anywhere you
19
+ need a plain JS object.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from dataclasses import dataclass
26
+ from typing import TYPE_CHECKING, override
27
+
28
+ from pulse.transpiler.errors import TranspileError
29
+ from pulse.transpiler.nodes import Expr, Object, Spread, spread_dict
30
+ from pulse.transpiler.vdom import VDOMNode
31
+
32
+ # TYPE_CHECKING avoids import cycle: Transpiler -> nodes -> Expr -> obj -> Transpiler
33
+ if TYPE_CHECKING:
34
+ from pulse.transpiler.transpiler import Transpiler
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class ObjTransformer(Expr):
39
+ """Transformer for obj() with **spread support.
40
+
41
+ obj(key=value, ...) -> { key: value, ... }
42
+ obj(**base, key=value) -> { ...base, key: value }
43
+
44
+ Creates a plain JavaScript object literal.
45
+ Use this instead of dict() when you need a plain object (e.g., for React props).
46
+ """
47
+
48
+ @override
49
+ def emit(self, out: list[str]) -> None:
50
+ raise TypeError("obj cannot be emitted directly - must be called")
51
+
52
+ @override
53
+ def render(self) -> VDOMNode:
54
+ raise TypeError("obj cannot be rendered - must be called")
55
+
56
+ @override
57
+ def transpile_call(
58
+ self,
59
+ args: list[ast.expr],
60
+ keywords: list[ast.keyword],
61
+ ctx: Transpiler,
62
+ ) -> Expr:
63
+ if args:
64
+ raise TranspileError("obj() only accepts keyword arguments")
65
+
66
+ props: list[tuple[str, Expr] | Spread] = []
67
+ for kw in keywords:
68
+ if kw.arg is None:
69
+ # **spread syntax
70
+ props.append(spread_dict(ctx.emit_expr(kw.value)))
71
+ else:
72
+ # key=value
73
+ props.append((kw.arg, ctx.emit_expr(kw.value)))
74
+
75
+ return Object(props)
76
+
77
+
78
+ # Create singleton instance for use as a callable
79
+ obj = ObjTransformer()
pulse/js/pulse.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Pulse UI client bindings for channel communication.
3
+
4
+ Usage:
5
+ from pulse.js.pulse import usePulseChannel, ChannelBridge, PulseChannelResetError
6
+
7
+ @ps.javascript(jsx=True)
8
+ def MyChannelComponent(*, channel_id: str):
9
+ bridge = usePulseChannel(channel_id)
10
+
11
+ # Subscribe to events
12
+ useEffect(
13
+ lambda: bridge.on("server:notify", lambda payload: console.log(payload)),
14
+ [bridge],
15
+ )
16
+
17
+ # Emit events to server
18
+ def send_ping():
19
+ bridge.emit("client:ping", {"message": "hello"})
20
+
21
+ # Make requests to server
22
+ async def send_request():
23
+ response = await bridge.request("client:request", {"data": 123})
24
+ console.log(response)
25
+
26
+ return ps.div()[
27
+ ps.button(onClick=send_ping)["Send Ping"],
28
+ ps.button(onClick=send_request)["Send Request"],
29
+ ]
30
+ """
31
+
32
+ from collections.abc import Awaitable as _Awaitable
33
+ from collections.abc import Callable as _Callable
34
+ from typing import Any as _Any
35
+ from typing import TypeVar as _TypeVar
36
+
37
+ from pulse.transpiler.js_module import JsModule
38
+
39
+ T = _TypeVar("T")
40
+
41
+
42
+ class PulseChannelResetError(Exception):
43
+ """Error raised when a channel is closed or reset."""
44
+
45
+ pass
46
+
47
+
48
+ class ChannelBridge:
49
+ """A bridge for bidirectional communication between client and server.
50
+
51
+ Provides methods for emitting events, making requests, and subscribing
52
+ to server events on a specific channel.
53
+ """
54
+
55
+ @property
56
+ def id(self) -> str:
57
+ """The unique channel identifier."""
58
+ ...
59
+
60
+ def emit(self, event: str, payload: _Any = None) -> None:
61
+ """Emit an event to the server.
62
+
63
+ Args:
64
+ event: The event name to emit.
65
+ payload: Optional data to send with the event.
66
+ """
67
+ ...
68
+
69
+ def request(self, event: str, payload: _Any = None) -> _Awaitable[_Any]:
70
+ """Make a request to the server and await a response.
71
+
72
+ Args:
73
+ event: The event name to send.
74
+ payload: Optional data to send with the request.
75
+
76
+ Returns:
77
+ A Promise that resolves with the server's response.
78
+ """
79
+ ...
80
+
81
+ def on(self, event: str, handler: _Callable[[_Any], _Any]) -> _Callable[[], None]:
82
+ """Subscribe to events from the server.
83
+
84
+ Args:
85
+ event: The event name to listen for.
86
+ handler: A callback function that receives the event payload.
87
+ May be sync or async. For request events, the return value
88
+ is sent back to the server.
89
+
90
+ Returns:
91
+ A cleanup function that unsubscribes the handler.
92
+ """
93
+ ...
94
+
95
+
96
+ def usePulseChannel(channel_id: str) -> ChannelBridge:
97
+ """React hook to connect to a Pulse channel.
98
+
99
+ Must be called from within a React component. The channel connection
100
+ is automatically managed based on component lifecycle.
101
+
102
+ Args:
103
+ channel_id: The unique identifier for the channel to connect to.
104
+
105
+ Returns:
106
+ A ChannelBridge instance for interacting with the channel.
107
+ """
108
+ ...
109
+
110
+
111
+ # Register as a JS module with named imports from pulse-ui-client
112
+ JsModule.register(name="pulse", src="pulse-ui-client", values="named_import")
pulse/js/react.py ADDED
@@ -0,0 +1,350 @@
1
+ """
2
+ JavaScript React module.
3
+
4
+ Usage:
5
+ from pulse.js.react import useState, useEffect, useRef
6
+ state, setState = useState(0) # -> const [state, setState] = useState(0)
7
+ useEffect(lambda: print("hi"), []) # -> useEffect(() => console.log("hi"), [])
8
+ ref = useRef(None) # -> const ref = useRef(null)
9
+
10
+ # Also available as namespace:
11
+ import pulse.js.react as React
12
+ React.useState(0) # -> React.useState(0)
13
+ """
14
+
15
+ from collections.abc import Callable as _Callable
16
+ from typing import Any as _Any
17
+ from typing import Protocol as _Protocol
18
+ from typing import TypeVar as _TypeVar
19
+
20
+ from pulse.transpiler.js_module import JsModule
21
+
22
+ # Type variables for hooks
23
+ T = _TypeVar("T")
24
+ T_co = _TypeVar("T_co", covariant=True)
25
+ T_contra = _TypeVar("T_contra", contravariant=True)
26
+ S = _TypeVar("S")
27
+ A = _TypeVar("A")
28
+
29
+
30
+ # =============================================================================
31
+ # React Types
32
+ # =============================================================================
33
+
34
+
35
+ class RefObject(_Protocol[T_co]):
36
+ """Type for useRef return value."""
37
+
38
+ @property
39
+ def current(self) -> T_co: ...
40
+
41
+
42
+ class MutableRefObject(_Protocol[T]):
43
+ """Type for useRef return value with mutable current."""
44
+
45
+ @property
46
+ def current(self) -> T: ...
47
+
48
+ @current.setter
49
+ def current(self, value: T) -> None: ...
50
+
51
+
52
+ class Dispatch(_Protocol[T_contra]):
53
+ """Type for setState/dispatch functions."""
54
+
55
+ def __call__(self, action: T_contra, /) -> None: ...
56
+
57
+
58
+ class TransitionStartFunction(_Protocol):
59
+ """Type for startTransition callback."""
60
+
61
+ def __call__(self, callback: _Callable[[], None], /) -> None: ...
62
+
63
+
64
+ class Context(_Protocol[T_co]):
65
+ """Type for React Context."""
66
+
67
+ @property
68
+ def Provider(self) -> _Any: ...
69
+
70
+ @property
71
+ def Consumer(self) -> _Any: ...
72
+
73
+
74
+ class ReactNode(_Protocol):
75
+ """Type for React children."""
76
+
77
+ ...
78
+
79
+
80
+ class ReactElement(_Protocol):
81
+ """Type for React element."""
82
+
83
+ @property
84
+ def type(self) -> _Any: ...
85
+
86
+ @property
87
+ def props(self) -> _Any: ...
88
+
89
+ @property
90
+ def key(self) -> str | None: ...
91
+
92
+
93
+ # =============================================================================
94
+ # State Hooks
95
+ # =============================================================================
96
+
97
+
98
+ def useState(
99
+ initial_state: S | _Callable[[], S],
100
+ ) -> tuple[S, Dispatch[S | _Callable[[S], S]]]:
101
+ """Returns a stateful value and a function to update it.
102
+
103
+ Example:
104
+ count, set_count = useState(0)
105
+ set_count(count + 1)
106
+ set_count(lambda prev: prev + 1)
107
+ """
108
+ ...
109
+
110
+
111
+ def useReducer(
112
+ reducer: _Callable[[S, A], S],
113
+ initial_arg: S,
114
+ init: _Callable[[S], S] | None = None,
115
+ ) -> tuple[S, Dispatch[A]]:
116
+ """An alternative to useState for complex state logic.
117
+
118
+ Example:
119
+ def reducer(state, action):
120
+ if action['type'] == 'increment':
121
+ return {'count': state['count'] + 1}
122
+ return state
123
+
124
+ state, dispatch = useReducer(reducer, {'count': 0})
125
+ dispatch({'type': 'increment'})
126
+ """
127
+ ...
128
+
129
+
130
+ # =============================================================================
131
+ # Effect Hooks
132
+ # =============================================================================
133
+
134
+
135
+ def useEffect(
136
+ effect: _Callable[[], None | _Callable[[], None]],
137
+ deps: list[_Any] | None = None,
138
+ ) -> None:
139
+ """Accepts a function that contains imperative, possibly effectful code.
140
+
141
+ Example:
142
+ useEffect(lambda: print("mounted"), [])
143
+ useEffect(lambda: (print("update"), lambda: print("cleanup"))[-1], [dep])
144
+ """
145
+ ...
146
+
147
+
148
+ def useLayoutEffect(
149
+ effect: _Callable[[], None | _Callable[[], None]],
150
+ deps: list[_Any] | None = None,
151
+ ) -> None:
152
+ """Like useEffect, but fires synchronously after all DOM mutations.
153
+
154
+ Example:
155
+ useLayoutEffect(lambda: measure_element(), [])
156
+ """
157
+ ...
158
+
159
+
160
+ def useInsertionEffect(
161
+ effect: _Callable[[], None | _Callable[[], None]],
162
+ deps: list[_Any] | None = None,
163
+ ) -> None:
164
+ """Like useLayoutEffect, but fires before any DOM mutations.
165
+ Use for CSS-in-JS libraries.
166
+ """
167
+ ...
168
+
169
+
170
+ # =============================================================================
171
+ # Ref Hooks
172
+ # =============================================================================
173
+
174
+
175
+ def useRef(initial_value: T) -> MutableRefObject[T]:
176
+ """Returns a mutable ref object.
177
+
178
+ Example:
179
+ input_ref = useRef(None)
180
+ # In JSX: <input ref={input_ref} />
181
+ input_ref.current.focus()
182
+ """
183
+ ...
184
+
185
+
186
+ def useImperativeHandle(
187
+ ref: RefObject[T] | _Callable[[T | None], None] | None,
188
+ create_handle: _Callable[[], T],
189
+ deps: list[_Any] | None = None,
190
+ ) -> None:
191
+ """Customizes the instance value exposed to parent components when using ref."""
192
+ ...
193
+
194
+
195
+ # =============================================================================
196
+ # Performance Hooks
197
+ # =============================================================================
198
+
199
+
200
+ def useMemo(factory: _Callable[[], T], deps: list[_Any]) -> T:
201
+ """Returns a memoized value.
202
+
203
+ Example:
204
+ expensive = useMemo(lambda: compute_expensive(a, b), [a, b])
205
+ """
206
+ ...
207
+
208
+
209
+ def useCallback(callback: T, deps: list[_Any]) -> T:
210
+ """Returns a memoized callback.
211
+
212
+ Example:
213
+ handle_click = useCallback(lambda e: print(e), [])
214
+ """
215
+ ...
216
+
217
+
218
+ def useDeferredValue(value: T) -> T:
219
+ """Defers updating a part of the UI. Returns a deferred version of the value."""
220
+ ...
221
+
222
+
223
+ def useTransition() -> tuple[bool, TransitionStartFunction]:
224
+ """Returns a stateful value for pending state and a function to start transition.
225
+
226
+ Example:
227
+ is_pending, start_transition = useTransition()
228
+ start_transition(lambda: set_state(new_value))
229
+ """
230
+ ...
231
+
232
+
233
+ # =============================================================================
234
+ # Context Hooks
235
+ # =============================================================================
236
+
237
+
238
+ def useContext(context: Context[T]) -> T:
239
+ """Returns the current context value for the given context.
240
+
241
+ Example:
242
+ theme = useContext(ThemeContext)
243
+ """
244
+ ...
245
+
246
+
247
+ # =============================================================================
248
+ # Other Hooks
249
+ # =============================================================================
250
+
251
+
252
+ def useId() -> str:
253
+ """Generates a unique ID that is stable across server and client.
254
+
255
+ Example:
256
+ id = useId()
257
+ # <label htmlFor={id}>Name</label>
258
+ # <input id={id} />
259
+ """
260
+ ...
261
+
262
+
263
+ def useDebugValue(value: T, format_fn: _Callable[[T], _Any] | None = None) -> None:
264
+ """Displays a label in React DevTools for custom hooks."""
265
+ ...
266
+
267
+
268
+ def useSyncExternalStore(
269
+ subscribe: _Callable[[_Callable[[], None]], _Callable[[], None]],
270
+ get_snapshot: _Callable[[], T],
271
+ get_server_snapshot: _Callable[[], T] | None = None,
272
+ ) -> T:
273
+ """Subscribe to an external store.
274
+
275
+ Example:
276
+ width = useSyncExternalStore(
277
+ subscribe_to_resize,
278
+ lambda: window.innerWidth
279
+ )
280
+ """
281
+ ...
282
+
283
+
284
+ # =============================================================================
285
+ # React Components and Elements
286
+ # =============================================================================
287
+
288
+
289
+ def createElement(
290
+ type: _Any,
291
+ props: dict[str, _Any] | None = None,
292
+ *children: _Any,
293
+ ) -> ReactElement:
294
+ """Creates a React element."""
295
+ ...
296
+
297
+
298
+ def cloneElement(
299
+ element: ReactElement,
300
+ props: dict[str, _Any] | None = None,
301
+ *children: _Any,
302
+ ) -> ReactElement:
303
+ """Clones and returns a new React element."""
304
+ ...
305
+
306
+
307
+ def isValidElement(obj: _Any) -> bool:
308
+ """Checks if the object is a React element."""
309
+ ...
310
+
311
+
312
+ def memo(component: T, are_equal: _Callable[[_Any, _Any], bool] | None = None) -> T:
313
+ """Memoizes a component to skip re-rendering when props are unchanged."""
314
+ ...
315
+
316
+
317
+ def forwardRef(
318
+ render: _Callable[[_Any, _Any], ReactElement | None],
319
+ ) -> _Callable[..., ReactElement | None]:
320
+ """Lets your component expose a DOM node to a parent component with a ref."""
321
+ ...
322
+
323
+
324
+ def lazy(load: _Callable[[], _Any]) -> _Any:
325
+ """Lets you defer loading a component's code until it is rendered."""
326
+ ...
327
+
328
+
329
+ def createContext(default_value: T) -> Context[T]:
330
+ """Creates a Context object."""
331
+ ...
332
+
333
+
334
+ # =============================================================================
335
+ # Fragments
336
+ # =============================================================================
337
+
338
+
339
+ class Fragment:
340
+ """Lets you group elements without a wrapper node."""
341
+
342
+ ...
343
+
344
+
345
+ # =============================================================================
346
+ # Registration
347
+ # =============================================================================
348
+
349
+ # React is a namespace module where each hook is a named import
350
+ JsModule.register(name="React", src="react", kind="namespace", values="named_import")
pulse/js/react_dom.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ JavaScript ReactDOM module.
3
+
4
+ Usage:
5
+ from pulse.js.react_dom import createPortal
6
+ createPortal(children, container) # -> createPortal(children, container)
7
+
8
+ # Also available as namespace:
9
+ import pulse.js.react_dom as ReactDOM
10
+ ReactDOM.createPortal(children, container)
11
+ """
12
+
13
+ from typing import Any as _Any
14
+
15
+ # Import types from react module for consistency
16
+ from pulse.js.react import ReactNode as ReactNode
17
+ from pulse.transpiler.js_module import JsModule
18
+
19
+
20
+ def createPortal(
21
+ children: ReactNode, container: _Any, key: str | None = None
22
+ ) -> ReactNode:
23
+ """Creates a portal to render children into a different DOM subtree."""
24
+ ...
25
+
26
+
27
+ # ReactDOM is a namespace module where each export is a named import
28
+ JsModule.register(
29
+ name="ReactDOM", src="react-dom", kind="namespace", values="named_import"
30
+ )
pulse/messages.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from typing import Any, Literal, NotRequired, TypedDict
2
2
 
3
3
  from pulse.routing import RouteInfo
4
- from pulse.transpiler.vdom import VDOM, VDOMOperation
4
+ from pulse.transpiler.vdom import VDOM, VDOMNode, VDOMOperation
5
5
 
6
6
 
7
7
  # ====================
@@ -80,12 +80,12 @@ class ServerChannelResponseMessage(TypedDict):
80
80
 
81
81
 
82
82
  class ServerJsExecMessage(TypedDict):
83
- """Execute JavaScript code on the client."""
83
+ """Execute JavaScript expression on the client."""
84
84
 
85
85
  type: Literal["js_exec"]
86
86
  path: str
87
87
  id: str
88
- code: str
88
+ expr: VDOMNode
89
89
 
90
90
 
91
91
  # ====================
@@ -98,20 +98,20 @@ class ClientCallbackMessage(TypedDict):
98
98
  args: list[Any]
99
99
 
100
100
 
101
- class ClientMountMessage(TypedDict):
102
- type: Literal["mount"]
101
+ class ClientAttachMessage(TypedDict):
102
+ type: Literal["attach"]
103
103
  path: str
104
104
  routeInfo: RouteInfo
105
105
 
106
106
 
107
- class ClientNavigateMessage(TypedDict):
108
- type: Literal["navigate"]
107
+ class ClientUpdateMessage(TypedDict):
108
+ type: Literal["update"]
109
109
  path: str
110
110
  routeInfo: RouteInfo
111
111
 
112
112
 
113
- class ClientUnmountMessage(TypedDict):
114
- type: Literal["unmount"]
113
+ class ClientDetachMessage(TypedDict):
114
+ type: Literal["detach"]
115
115
  path: str
116
116
 
117
117
 
@@ -165,9 +165,9 @@ ServerMessage = (
165
165
 
166
166
  ClientPulseMessage = (
167
167
  ClientCallbackMessage
168
- | ClientMountMessage
169
- | ClientNavigateMessage
170
- | ClientUnmountMessage
168
+ | ClientAttachMessage
169
+ | ClientUpdateMessage
170
+ | ClientDetachMessage
171
171
  | ClientApiResultMessage
172
172
  | ClientJsResultMessage
173
173
  )
@@ -193,5 +193,5 @@ class Directives(TypedDict):
193
193
 
194
194
 
195
195
  class Prerender(TypedDict):
196
- views: dict[str, ServerInitMessage | None]
196
+ views: dict[str, ServerInitMessage | ServerNavigateToMessage | None]
197
197
  directives: Directives
pulse/proxy.py CHANGED
@@ -15,6 +15,9 @@ from starlette.responses import PlainTextResponse, Response
15
15
  from starlette.websockets import WebSocket, WebSocketDisconnect
16
16
  from websockets.typing import Subprotocol
17
17
 
18
+ from pulse.context import PulseContext
19
+ from pulse.cookies import parse_cookie_header
20
+
18
21
  logger = logging.getLogger(__name__)
19
22
 
20
23
 
@@ -108,11 +111,7 @@ class ReactProxy:
108
111
  )
109
112
  }
110
113
 
111
- # Accept the client WebSocket connection first
112
- # We'll accept without subprotocol initially, then update if target accepts one
113
- await websocket.accept()
114
-
115
- # Connect to target WebSocket server
114
+ # Connect to target WebSocket server first to negotiate subprotocol
116
115
  try:
117
116
  async with websockets.connect(
118
117
  target_url,
@@ -120,6 +119,9 @@ class ReactProxy:
120
119
  subprotocols=subprotocols,
121
120
  ping_interval=None, # Let the target server handle ping/pong
122
121
  ) as target_ws:
122
+ # Accept client connection with the negotiated subprotocol
123
+ await websocket.accept(subprotocol=target_ws.subprotocol)
124
+
123
125
  # Forward messages bidirectionally
124
126
  async def forward_client_to_target():
125
127
  try:
@@ -190,6 +192,17 @@ class ReactProxy:
190
192
 
191
193
  # Extract headers, skip host header (will be set by httpx)
192
194
  headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
195
+ ctx = PulseContext.get()
196
+ session = ctx.session
197
+ if session is not None:
198
+ session_cookie = session.get_cookie_value(ctx.app.cookie.name)
199
+ if session_cookie:
200
+ existing = parse_cookie_header(headers.get("cookie"))
201
+ if existing.get(ctx.app.cookie.name) != session_cookie:
202
+ existing[ctx.app.cookie.name] = session_cookie
203
+ headers["cookie"] = "; ".join(
204
+ f"{key}={value}" for key, value in existing.items()
205
+ )
193
206
 
194
207
  try:
195
208
  # Build request