pulse-framework 0.1.39__py3-none-any.whl → 0.1.41__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/reactive.py CHANGED
@@ -1,18 +1,23 @@
1
1
  import asyncio
2
2
  import copy
3
3
  import inspect
4
- from collections.abc import Callable, Coroutine
4
+ from collections.abc import Awaitable, Callable
5
5
  from contextvars import ContextVar, Token
6
6
  from typing import (
7
7
  Any,
8
8
  Generic,
9
9
  ParamSpec,
10
10
  TypeVar,
11
- cast,
12
11
  override,
13
12
  )
14
13
 
15
- from pulse.helpers import create_task, schedule_on_loop, values_equal
14
+ from pulse.helpers import (
15
+ Disposable,
16
+ create_task,
17
+ maybe_await,
18
+ schedule_on_loop,
19
+ values_equal,
20
+ )
16
21
 
17
22
  T = TypeVar("T")
18
23
  P = ParamSpec("P")
@@ -94,6 +99,7 @@ class Computed(Generic[T]):
94
99
  name: str | None
95
100
  dirty: bool
96
101
  on_stack: bool
102
+ accepts_prev_value: bool
97
103
 
98
104
  def __init__(self, fn: Callable[..., T], name: str | None = None):
99
105
  self.fn = fn
@@ -106,6 +112,18 @@ class Computed(Generic[T]):
106
112
  self.deps: dict[Signal[Any] | Computed[Any], int] = {}
107
113
  self.obs: list[Computed[Any] | Effect] = []
108
114
  self._obs_change_listeners: list[Callable[[int], None]] = []
115
+ sig = inspect.signature(self.fn)
116
+ params = list(sig.parameters.values())
117
+ # Check if function has at least one positional parameter
118
+ # (excluding *args and **kwargs, and keyword-only params)
119
+ self.accepts_prev_value = any(
120
+ p.kind
121
+ in (
122
+ inspect.Parameter.POSITIONAL_ONLY,
123
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
124
+ )
125
+ for p in params
126
+ )
109
127
 
110
128
  def read(self) -> T:
111
129
  if self.on_stack:
@@ -156,7 +174,10 @@ class Computed(Generic[T]):
156
174
  self.on_stack = True
157
175
  try:
158
176
  execution_epoch = epoch()
159
- self.value = self.fn()
177
+ if self.accepts_prev_value:
178
+ self.value = self.fn(prev_value)
179
+ else:
180
+ self.value = self.fn()
160
181
  if epoch() != execution_epoch:
161
182
  raise RuntimeError(
162
183
  f"Detected write to a signal in computed {self.name}. Computeds should be read-only."
@@ -231,10 +252,10 @@ class Computed(Generic[T]):
231
252
  EffectCleanup = Callable[[], None]
232
253
  # Split effect function types into sync and async for clearer typing
233
254
  EffectFn = Callable[[], EffectCleanup | None]
234
- AsyncEffectFn = Callable[[], Coroutine[Any, Any, EffectCleanup | None]]
255
+ AsyncEffectFn = Callable[[], Awaitable[EffectCleanup | None]]
235
256
 
236
257
 
237
- class Effect:
258
+ class Effect(Disposable):
238
259
  """
239
260
  Synchronous effect and base class. Use AsyncEffect for async effects.
240
261
  Both are isinstance(Effect).
@@ -247,6 +268,7 @@ class Effect:
247
268
  last_run: int
248
269
  immediate: bool
249
270
  _lazy: bool
271
+ explicit_deps: bool
250
272
  batch: "Batch | None"
251
273
 
252
274
  def __init__(
@@ -269,13 +291,19 @@ class Effect:
269
291
  self.last_run = -1
270
292
  self.scope: Scope | None = None
271
293
  self.batch = None
272
- self._explicit_deps: list[Signal[Any] | Computed[Any]] | None = deps
294
+ self.explicit_deps = deps is not None
273
295
  self.immediate = immediate
274
296
  self._lazy = lazy
275
297
 
276
298
  if immediate and lazy:
277
299
  raise ValueError("An effect cannot be boht immediate and lazy")
278
300
 
301
+ # Register explicit dependencies immediately upon initialization
302
+ if deps is not None:
303
+ self.deps = {dep: dep.last_change for dep in deps}
304
+ for dep in deps:
305
+ dep.add_obs(self)
306
+
279
307
  rc = REACTIVE_CONTEXT.get()
280
308
  if rc.scope is not None:
281
309
  rc.scope.register_effect(self)
@@ -291,6 +319,7 @@ class Effect:
291
319
  if self.cleanup_fn:
292
320
  self.cleanup_fn()
293
321
 
322
+ @override
294
323
  def dispose(self):
295
324
  self.unschedule()
296
325
  for child in self.children.copy():
@@ -353,15 +382,24 @@ class Effect:
353
382
  return
354
383
  raise exc
355
384
 
356
- def _apply_scope_results(self, scope: "Scope") -> None:
385
+ def _apply_scope_results(
386
+ self,
387
+ scope: "Scope",
388
+ captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None,
389
+ ) -> None:
390
+ # Apply captured last_change values at the end for explicit deps
391
+ if self.explicit_deps:
392
+ assert captured_last_changes is not None
393
+ for dep, last_change in captured_last_changes.items():
394
+ self.deps[dep] = last_change
395
+ return
396
+
357
397
  self.children = scope.effects
358
398
  for child in self.children:
359
399
  child.parent = self
360
400
 
361
401
  prev_deps = set(self.deps)
362
- if self._explicit_deps is not None:
363
- self.deps = {dep: dep.last_change for dep in self._explicit_deps}
364
- else:
402
+ if not self.explicit_deps:
365
403
  self.deps = scope.deps
366
404
  new_deps = set(self.deps)
367
405
  add_deps = new_deps - prev_deps
@@ -379,8 +417,8 @@ class Effect:
379
417
 
380
418
  def _copy_kwargs(self) -> dict[str, Any]:
381
419
  deps = None
382
- if self._explicit_deps is not None:
383
- deps = list(self._explicit_deps)
420
+ if self.explicit_deps:
421
+ deps = list(self.deps.keys())
384
422
  return {
385
423
  "fn": self.fn,
386
424
  "name": self.name,
@@ -418,6 +456,10 @@ class Effect:
418
456
 
419
457
  def _execute(self) -> None:
420
458
  execution_epoch = epoch()
459
+ # Capture last_change for explicit deps before running
460
+ captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None
461
+ if self.explicit_deps:
462
+ captured_last_changes = {dep: dep.last_change for dep in self.deps}
421
463
  with Scope() as scope:
422
464
  # Clear batch *before* running as we may update a signal that causes
423
465
  # this effect to be rescheduled.
@@ -428,11 +470,13 @@ class Effect:
428
470
  self.handle_error(e)
429
471
  self.runs += 1
430
472
  self.last_run = execution_epoch
431
- self._apply_scope_results(scope)
473
+ self._apply_scope_results(scope, captured_last_changes)
432
474
 
433
475
 
434
476
  class AsyncEffect(Effect):
435
- batch: "Batch | None"
477
+ fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
478
+ batch: None # pyright: ignore[reportIncompatibleVariableOverride]
479
+ _task: asyncio.Task[None] | None
436
480
 
437
481
  def __init__(
438
482
  self,
@@ -442,6 +486,8 @@ class AsyncEffect(Effect):
442
486
  on_error: Callable[[Exception], None] | None = None,
443
487
  deps: list[Signal[Any] | Computed[Any]] | None = None,
444
488
  ):
489
+ # Track an async task when running async effects
490
+ self._task = None
445
491
  super().__init__(
446
492
  fn=fn, # pyright: ignore[reportArgumentType]
447
493
  name=name,
@@ -450,12 +496,19 @@ class AsyncEffect(Effect):
450
496
  on_error=on_error,
451
497
  deps=deps,
452
498
  )
453
- # Track an async task when running async effects
454
- self._task: asyncio.Task[Any] | None = None
455
499
 
456
- def _task_name(self) -> str:
457
- base = self.name or "effect"
458
- return f"effect:{base}"
500
+ @override
501
+ def schedule(self):
502
+ """
503
+ Schedule the async effect. Unlike synchronous effects, async effects do not
504
+ go through batches, they cancel the previous run and create a new task
505
+ immediately..
506
+ """
507
+ self.run()
508
+
509
+ @property
510
+ def is_scheduled(self) -> bool:
511
+ return self._task is not None
459
512
 
460
513
  @override
461
514
  def _copy_kwargs(self):
@@ -464,48 +517,91 @@ class AsyncEffect(Effect):
464
517
  return kwargs
465
518
 
466
519
  @override
467
- def _execute(self) -> None:
520
+ def run(self) -> asyncio.Task[Any]: # pyright: ignore[reportIncompatibleMethodOverride]
521
+ """
522
+ Run the async effect immediately, cancelling any previous run.
523
+ Returns the asyncio.Task.
524
+ """
468
525
  execution_epoch = epoch()
469
526
 
470
- # Clear batch *before* running as we may update a signal that causes
471
- # this effect to be rescheduled.
472
- self.batch = None
473
-
474
527
  # Cancel any previous run still in flight
475
528
  self.cancel()
529
+ this_task: asyncio.Task[None] | None = None
476
530
 
477
531
  async def _runner():
478
- nonlocal execution_epoch
479
- with Scope() as scope:
480
- try:
481
- result = cast(AsyncEffectFn, self.fn)()
482
- if inspect.isawaitable(result):
483
- self.cleanup_fn = await result
484
- else:
485
- # Support accidental non-async returns in async-annotated fns
486
- self.cleanup_fn = result
487
- except asyncio.CancelledError:
488
- # Swallow cancellation
489
- return
490
- except Exception as e:
491
- self.handle_error(e)
492
- self.runs += 1
493
- self.last_run = execution_epoch
494
- self._apply_scope_results(scope)
495
-
496
- self._task = create_task(_runner(), name=self._task_name())
532
+ nonlocal execution_epoch, this_task
533
+ try:
534
+ # Perform cleanups in the new task
535
+ with Untrack():
536
+ try:
537
+ self._cleanup_before_run()
538
+ except Exception as e:
539
+ self.handle_error(e)
540
+
541
+ # Capture last_change for explicit deps before running
542
+ captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = (
543
+ None
544
+ )
545
+ if self.explicit_deps:
546
+ captured_last_changes = {dep: dep.last_change for dep in self.deps}
547
+
548
+ with Scope() as scope:
549
+ try:
550
+ result = self.fn()
551
+ self.cleanup_fn = await maybe_await(result)
552
+ except asyncio.CancelledError:
553
+ # Re-raise so finally block executes to clear task reference
554
+ raise
555
+ except Exception as e:
556
+ self.handle_error(e)
557
+ self.runs += 1
558
+ self.last_run = execution_epoch
559
+ self._apply_scope_results(scope, captured_last_changes)
560
+ finally:
561
+ # Clear the task reference when it finishes
562
+ if self._task is this_task:
563
+ self._task = None
564
+
565
+ this_task = create_task(_runner(), name=f"effect:{self.name or 'unnamed'}")
566
+ self._task = this_task
567
+ return this_task
568
+
569
+ @override
570
+ async def __call__(self): # pyright: ignore[reportIncompatibleMethodOverride]
571
+ await self.run()
497
572
 
498
573
  def cancel(self) -> None:
499
- self.unschedule()
500
- if self._task and not self._task.done():
501
- self._task.cancel()
574
+ # No batch removal needed as AsyncEffect is not batched
575
+ if self._task:
576
+ t = self._task
577
+ self._task = None
578
+ if not t.cancelled():
579
+ t.cancel()
580
+
581
+ async def wait(self) -> None:
582
+ """
583
+ Wait until the completion of the current task if it's already running,
584
+ or start a run if it's not running. In case of cancellation, awaits
585
+ the new task by recursively calling itself.
586
+ """
587
+ while True:
588
+ try:
589
+ await (self._task or self.run())
590
+ return
591
+ except asyncio.CancelledError:
592
+ # If wait() itself is cancelled, propagate it
593
+ current_task = asyncio.current_task()
594
+ if current_task is not None and (
595
+ current_task.cancelling() > 0 or current_task.cancelled()
596
+ ):
597
+ raise
598
+ # Effect task was cancelled, continue waiting for new task
599
+ continue
502
600
 
503
601
  @override
504
602
  def dispose(self):
505
603
  # Run children cleanups first, then cancel in-flight task
506
- self.unschedule()
507
- if self._task and not self._task.done():
508
- self._task.cancel()
604
+ self.cancel()
509
605
  for child in self.children.copy():
510
606
  child.dispose()
511
607
  if self.cleanup_fn:
pulse/render_session.py CHANGED
@@ -18,6 +18,7 @@ from pulse.messages import (
18
18
  ServerNavigateToMessage,
19
19
  ServerUpdateMessage,
20
20
  )
21
+ from pulse.queries.store import QueryStore
21
22
  from pulse.reactive import Effect, flush_effects
22
23
  from pulse.renderer import RenderTree
23
24
  from pulse.routing import (
@@ -26,7 +27,7 @@ from pulse.routing import (
26
27
  RouteContext,
27
28
  RouteInfo,
28
29
  RouteTree,
29
- normalize_path,
30
+ ensure_absolute_path,
30
31
  )
31
32
  from pulse.state import State
32
33
  from pulse.vdom import Element
@@ -60,6 +61,7 @@ class RenderSession:
60
61
  routes: RouteTree
61
62
  channels: "ChannelsManager"
62
63
  forms: "FormRegistry"
64
+ query_store: QueryStore
63
65
 
64
66
  def __init__(
65
67
  self,
@@ -85,6 +87,7 @@ class RenderSession:
85
87
  self._pending_api: dict[str, asyncio.Future[dict[str, Any]]] = {}
86
88
  # Registry of per-session global singletons (created via ps.global_state without id)
87
89
  self._global_states: dict[str, State] = {}
90
+ self.query_store = QueryStore()
88
91
  # Connection state
89
92
  self.connected: bool = False
90
93
  self.channels = ChannelsManager(self)
@@ -353,7 +356,7 @@ class RenderSession:
353
356
  self,
354
357
  path: str,
355
358
  ):
356
- path = normalize_path(path)
359
+ path = ensure_absolute_path(path)
357
360
  mount = self.route_mounts.get(path)
358
361
  if not mount:
359
362
  raise ValueError(f"No active route for '{path}'")
pulse/routing.py CHANGED
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
4
4
  from typing import TypedDict, cast, override
5
5
 
6
6
  from pulse.css import CssImport, CssModule
7
+ from pulse.env import env
7
8
  from pulse.react_component import ReactComponent
8
9
  from pulse.reactive_extensions import ReactiveDict
9
10
  from pulse.vdom import Component
@@ -81,7 +82,7 @@ def parse_route_path(path: str) -> list[PathSegment]:
81
82
 
82
83
  # Normalize to react-router's convention: no leading and trailing slashes. Empty
83
84
  # string interpreted as the root.
84
- def normalize_path(path: str):
85
+ def ensure_relative_path(path: str):
85
86
  if path.startswith("/"):
86
87
  path = path[1:]
87
88
  if path.endswith("/"):
@@ -89,6 +90,12 @@ def normalize_path(path: str):
89
90
  return path
90
91
 
91
92
 
93
+ def ensure_absolute_path(path: str):
94
+ if not path.startswith("/"):
95
+ path = "/" + path
96
+ return path
97
+
98
+
92
99
  # ---- Shared helpers ----------------------------------------------------------
93
100
  def segments_are_dynamic(segments: list[PathSegment]) -> bool:
94
101
  """Return True if any segment is dynamic, optional, or a catch-all."""
@@ -157,6 +164,7 @@ class Route:
157
164
  css_imports: Sequence[CssImport] | None
158
165
  is_index: bool
159
166
  is_dynamic: bool
167
+ dev: bool
160
168
 
161
169
  def __init__(
162
170
  self,
@@ -166,8 +174,9 @@ class Route:
166
174
  components: "Sequence[ReactComponent[...]] | None" = None,
167
175
  css_modules: Sequence[CssModule] | None = None,
168
176
  css_imports: Sequence[CssImport] | None = None,
177
+ dev: bool = False,
169
178
  ):
170
- self.path = normalize_path(path)
179
+ self.path = ensure_relative_path(path)
171
180
  self.segments = parse_route_path(path)
172
181
 
173
182
  self.render = render
@@ -175,6 +184,7 @@ class Route:
175
184
  self.components = components
176
185
  self.css_modules = css_modules
177
186
  self.css_imports = css_imports
187
+ self.dev = dev
178
188
  self.parent: Route | Layout | None = None
179
189
 
180
190
  self.is_index = self.path == ""
@@ -191,8 +201,8 @@ class Route:
191
201
  return [path]
192
202
 
193
203
  def unique_path(self):
194
- # Ensure consistent keys without accidental leading/trailing slashes
195
- return normalize_path("/".join(self._path_list()))
204
+ # Return absolute path with leading '/'
205
+ return ensure_absolute_path("/".join(self._path_list()))
196
206
 
197
207
  def file_path(self) -> str:
198
208
  path = "/".join(self._path_list(include_layouts=False))
@@ -224,8 +234,7 @@ class Route:
224
234
  f"Cannot build default RouteInfo for dynamic route '{self.path}'."
225
235
  )
226
236
 
227
- unique = self.unique_path()
228
- pathname = "/" if unique == "" else f"/{unique}"
237
+ pathname = self.unique_path()
229
238
  return {
230
239
  "pathname": pathname,
231
240
  "hash": "",
@@ -250,6 +259,7 @@ class Layout:
250
259
  components: Sequence[ReactComponent[...]] | None
251
260
  css_modules: Sequence[CssModule] | None
252
261
  css_imports: Sequence[CssImport] | None
262
+ dev: bool
253
263
 
254
264
  def __init__(
255
265
  self,
@@ -258,12 +268,14 @@ class Layout:
258
268
  components: "Sequence[ReactComponent[...]] | None" = None,
259
269
  css_modules: Sequence[CssModule] | None = None,
260
270
  css_imports: Sequence[CssImport] | None = None,
271
+ dev: bool = False,
261
272
  ):
262
273
  self.render = render
263
274
  self.children = children or []
264
275
  self.components = components
265
276
  self.css_modules = css_modules
266
277
  self.css_imports = css_imports
278
+ self.dev = dev
267
279
  self.parent: Route | Layout | None = None
268
280
  # 1-based sibling index assigned by RouteTree at each level
269
281
  self.idx: int = 1
@@ -280,7 +292,9 @@ class Layout:
280
292
  return path_list
281
293
 
282
294
  def unique_path(self):
283
- return "/".join(self._path_list(include_layouts=True))
295
+ # Return absolute path with leading '/'
296
+ path = "/".join(self._path_list(include_layouts=True))
297
+ return f"/{path}"
284
298
 
285
299
  def file_path(self) -> str:
286
300
  path_list = self._path_list(include_layouts=True)
@@ -318,8 +332,7 @@ class Layout:
318
332
 
319
333
  # Build pathname from ancestor route path segments (exclude layout indicators)
320
334
  path_list = self._path_list(include_layouts=False)
321
- unique = normalize_path("/".join(path_list))
322
- pathname = "/" if unique == "" else f"/{unique}"
335
+ pathname = ensure_absolute_path("/".join(path_list))
323
336
  return {
324
337
  "pathname": pathname,
325
338
  "hash": "",
@@ -330,6 +343,48 @@ class Layout:
330
343
  }
331
344
 
332
345
 
346
+ def filter_dev_routes(routes: Sequence[Route | Layout]) -> list[Route | Layout]:
347
+ """
348
+ Filter out routes with dev=True.
349
+
350
+ This function removes all routes marked with dev=True from the route tree.
351
+ Should only be called when env != "dev".
352
+ """
353
+ filtered: list[Route | Layout] = []
354
+ for route in routes:
355
+ # Skip dev-only routes
356
+ if route.dev:
357
+ continue
358
+
359
+ # Recursively filter children
360
+ if route.children:
361
+ filtered_children = filter_dev_routes(route.children)
362
+ # Create a copy of the route with filtered children
363
+ if isinstance(route, Route):
364
+ filtered_route = Route(
365
+ path=route.path,
366
+ render=route.render,
367
+ children=filtered_children,
368
+ components=route.components,
369
+ css_modules=route.css_modules,
370
+ css_imports=route.css_imports,
371
+ dev=route.dev,
372
+ )
373
+ else: # Layout
374
+ filtered_route = Layout(
375
+ render=route.render,
376
+ children=filtered_children,
377
+ components=route.components,
378
+ css_modules=route.css_modules,
379
+ css_imports=route.css_imports,
380
+ dev=route.dev,
381
+ )
382
+ filtered.append(filtered_route)
383
+ else:
384
+ filtered.append(route)
385
+ return filtered
386
+
387
+
333
388
  class InvalidRouteError(Exception): ...
334
389
 
335
390
 
@@ -338,6 +393,9 @@ class RouteTree:
338
393
  flat_tree: dict[str, Route | Layout]
339
394
 
340
395
  def __init__(self, routes: Sequence[Route | Layout]) -> None:
396
+ # Filter out dev routes when not in dev environment
397
+ if env.pulse_env != "dev":
398
+ routes = filter_dev_routes(routes)
341
399
  self.tree = list(routes)
342
400
  self.flat_tree = {}
343
401
 
@@ -366,7 +424,7 @@ class RouteTree:
366
424
  _flatten_route_tree(route)
367
425
 
368
426
  def find(self, path: str):
369
- path = normalize_path(path)
427
+ path = ensure_absolute_path(path)
370
428
  route = self.flat_tree.get(path)
371
429
  if not route:
372
430
  raise ValueError(f"No route found for path '{path}'")
pulse/state.py CHANGED
@@ -11,6 +11,7 @@ from collections.abc import Callable, Iterator
11
11
  from enum import IntEnum
12
12
  from typing import Any, Generic, Never, TypeVar, override
13
13
 
14
+ from pulse.helpers import Disposable
14
15
  from pulse.reactive import (
15
16
  AsyncEffect,
16
17
  Computed,
@@ -185,7 +186,7 @@ class StateStatus(IntEnum):
185
186
  STATE_STATUS_FIELD = "__pulse_status__"
186
187
 
187
188
 
188
- class State(ABC, metaclass=StateMeta):
189
+ class State(Disposable, metaclass=StateMeta):
189
190
  """
190
191
  Base class for reactive state objects.
191
192
 
@@ -328,19 +329,19 @@ class State(ABC, metaclass=StateMeta):
328
329
  """
329
330
  pass
330
331
 
332
+ @override
331
333
  def dispose(self):
332
334
  # Call user-defined cleanup hook first
333
- self.on_dispose()
334
335
 
335
- disposed = set()
336
+ self.on_dispose()
336
337
  for value in self.__dict__.values():
337
- if isinstance(value, Effect):
338
+ if isinstance(value, Disposable):
338
339
  value.dispose()
339
- disposed.add(value)
340
340
 
341
- if len(set(self._scope.effects) - disposed) > 0:
341
+ undisposed_effects = [e for e in self._scope.effects if not e.__disposed__]
342
+ if len(undisposed_effects) > 0:
342
343
  raise RuntimeError(
343
- f"State.dispose() missed effects defined on its Scope: {[e.name for e in self._scope.effects]}"
344
+ f"State.dispose() missed effects defined on its Scope: {[e.name for e in undisposed_effects]}"
344
345
  )
345
346
 
346
347
  @override
@@ -1,10 +1,9 @@
1
- from collections.abc import Callable, Coroutine
1
+ from collections.abc import Awaitable, Callable
2
2
  from typing import (
3
- Any,
4
3
  TypeVar,
5
4
  )
6
5
 
7
- EventHandlerResult = None | Coroutine[Any, Any, None]
6
+ EventHandlerResult = None | Awaitable[None]
8
7
 
9
8
  T1 = TypeVar("T1", contravariant=True)
10
9
  T2 = TypeVar("T2", contravariant=True)
pulse/user_session.py CHANGED
@@ -12,6 +12,7 @@ from fastapi import Response
12
12
 
13
13
  from pulse.cookies import SetCookie
14
14
  from pulse.env import env
15
+ from pulse.helpers import Disposable
15
16
  from pulse.reactive import AsyncEffect, Effect
16
17
  from pulse.reactive_extensions import ReactiveDict, reactive
17
18
 
@@ -23,7 +24,7 @@ Session = ReactiveDict[str, Any]
23
24
  logger = logging.getLogger(__name__)
24
25
 
25
26
 
26
- class UserSession:
27
+ class UserSession(Disposable):
27
28
  sid: str
28
29
  data: Session
29
30
  app: "App"
@@ -65,8 +66,8 @@ class UserSession:
65
66
  max_age_seconds=app.cookie.max_age_seconds,
66
67
  )
67
68
 
69
+ @override
68
70
  def dispose(self):
69
- print(f"Closing session {self.sid}")
70
71
  self._effect.dispose()
71
72
 
72
73
  def handle_response(self, res: Response):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.39
3
+ Version: 0.1.41
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0