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/__init__.py +14 -4
- pulse/app.py +176 -126
- pulse/channel.py +7 -7
- pulse/cli/cmd.py +81 -45
- pulse/cli/models.py +2 -0
- pulse/cli/processes.py +67 -22
- pulse/cli/uvicorn_log_config.py +1 -1
- pulse/codegen/codegen.py +14 -1
- pulse/codegen/templates/layout.py +10 -2
- pulse/decorators.py +132 -40
- pulse/form.py +9 -9
- pulse/helpers.py +75 -11
- pulse/hooks/core.py +4 -3
- pulse/hooks/states.py +91 -54
- pulse/messages.py +1 -1
- pulse/middleware.py +170 -119
- pulse/plugin.py +0 -3
- pulse/proxy.py +168 -147
- pulse/queries/__init__.py +0 -0
- pulse/queries/common.py +24 -0
- pulse/queries/mutation.py +142 -0
- pulse/queries/query.py +270 -0
- pulse/queries/query_observer.py +365 -0
- pulse/queries/store.py +60 -0
- pulse/reactive.py +146 -50
- pulse/render_session.py +5 -2
- pulse/routing.py +68 -10
- pulse/state.py +8 -7
- pulse/types/event_handler.py +2 -3
- pulse/user_session.py +3 -2
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/RECORD +34 -29
- pulse/query.py +0 -408
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/entry_points.txt +0 -0
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
|
|
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
|
|
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
|
-
|
|
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[[],
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
383
|
-
deps = list(self.
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
self.
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
500
|
-
if self._task
|
|
501
|
-
self._task
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
#
|
|
195
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
336
|
+
self.on_dispose()
|
|
336
337
|
for value in self.__dict__.values():
|
|
337
|
-
if isinstance(value,
|
|
338
|
+
if isinstance(value, Disposable):
|
|
338
339
|
value.dispose()
|
|
339
|
-
disposed.add(value)
|
|
340
340
|
|
|
341
|
-
|
|
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
|
|
344
|
+
f"State.dispose() missed effects defined on its Scope: {[e.name for e in undisposed_effects]}"
|
|
344
345
|
)
|
|
345
346
|
|
|
346
347
|
@override
|
pulse/types/event_handler.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
from collections.abc import
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
2
|
from typing import (
|
|
3
|
-
Any,
|
|
4
3
|
TypeVar,
|
|
5
4
|
)
|
|
6
5
|
|
|
7
|
-
EventHandlerResult = 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):
|