pulse-framework 0.1.40__py3-none-any.whl → 0.1.42__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 +19 -4
- pulse/app.py +159 -99
- 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/context.py +3 -2
- pulse/decorators.py +132 -40
- pulse/form.py +9 -9
- pulse/helpers.py +75 -11
- pulse/hooks/core.py +7 -8
- pulse/hooks/init.py +460 -0
- pulse/hooks/states.py +91 -54
- pulse/messages.py +1 -1
- pulse/middleware.py +170 -119
- pulse/plugin.py +0 -3
- pulse/proxy.py +134 -16
- 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/react_component.py +2 -1
- pulse/reactive.py +153 -53
- 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/vdom.py +3 -1
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/RECORD +38 -32
- pulse/query.py +0 -408
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/entry_points.txt +0 -0
pulse/reactive.py
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
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
|
+
Literal,
|
|
9
10
|
ParamSpec,
|
|
10
11
|
TypeVar,
|
|
11
|
-
cast,
|
|
12
12
|
override,
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
from pulse.helpers import
|
|
15
|
+
from pulse.helpers import (
|
|
16
|
+
Disposable,
|
|
17
|
+
create_task,
|
|
18
|
+
maybe_await,
|
|
19
|
+
schedule_on_loop,
|
|
20
|
+
values_equal,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
T = TypeVar("T")
|
|
18
24
|
P = ParamSpec("P")
|
|
@@ -94,6 +100,7 @@ class Computed(Generic[T]):
|
|
|
94
100
|
name: str | None
|
|
95
101
|
dirty: bool
|
|
96
102
|
on_stack: bool
|
|
103
|
+
accepts_prev_value: bool
|
|
97
104
|
|
|
98
105
|
def __init__(self, fn: Callable[..., T], name: str | None = None):
|
|
99
106
|
self.fn = fn
|
|
@@ -106,6 +113,18 @@ class Computed(Generic[T]):
|
|
|
106
113
|
self.deps: dict[Signal[Any] | Computed[Any], int] = {}
|
|
107
114
|
self.obs: list[Computed[Any] | Effect] = []
|
|
108
115
|
self._obs_change_listeners: list[Callable[[int], None]] = []
|
|
116
|
+
sig = inspect.signature(self.fn)
|
|
117
|
+
params = list(sig.parameters.values())
|
|
118
|
+
# Check if function has at least one positional parameter
|
|
119
|
+
# (excluding *args and **kwargs, and keyword-only params)
|
|
120
|
+
self.accepts_prev_value = any(
|
|
121
|
+
p.kind
|
|
122
|
+
in (
|
|
123
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
124
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
125
|
+
)
|
|
126
|
+
for p in params
|
|
127
|
+
)
|
|
109
128
|
|
|
110
129
|
def read(self) -> T:
|
|
111
130
|
if self.on_stack:
|
|
@@ -156,7 +175,10 @@ class Computed(Generic[T]):
|
|
|
156
175
|
self.on_stack = True
|
|
157
176
|
try:
|
|
158
177
|
execution_epoch = epoch()
|
|
159
|
-
|
|
178
|
+
if self.accepts_prev_value:
|
|
179
|
+
self.value = self.fn(prev_value)
|
|
180
|
+
else:
|
|
181
|
+
self.value = self.fn()
|
|
160
182
|
if epoch() != execution_epoch:
|
|
161
183
|
raise RuntimeError(
|
|
162
184
|
f"Detected write to a signal in computed {self.name}. Computeds should be read-only."
|
|
@@ -231,10 +253,10 @@ class Computed(Generic[T]):
|
|
|
231
253
|
EffectCleanup = Callable[[], None]
|
|
232
254
|
# Split effect function types into sync and async for clearer typing
|
|
233
255
|
EffectFn = Callable[[], EffectCleanup | None]
|
|
234
|
-
AsyncEffectFn = Callable[[],
|
|
256
|
+
AsyncEffectFn = Callable[[], Awaitable[EffectCleanup | None]]
|
|
235
257
|
|
|
236
258
|
|
|
237
|
-
class Effect:
|
|
259
|
+
class Effect(Disposable):
|
|
238
260
|
"""
|
|
239
261
|
Synchronous effect and base class. Use AsyncEffect for async effects.
|
|
240
262
|
Both are isinstance(Effect).
|
|
@@ -247,6 +269,7 @@ class Effect:
|
|
|
247
269
|
last_run: int
|
|
248
270
|
immediate: bool
|
|
249
271
|
_lazy: bool
|
|
272
|
+
explicit_deps: bool
|
|
250
273
|
batch: "Batch | None"
|
|
251
274
|
|
|
252
275
|
def __init__(
|
|
@@ -269,13 +292,19 @@ class Effect:
|
|
|
269
292
|
self.last_run = -1
|
|
270
293
|
self.scope: Scope | None = None
|
|
271
294
|
self.batch = None
|
|
272
|
-
self.
|
|
295
|
+
self.explicit_deps = deps is not None
|
|
273
296
|
self.immediate = immediate
|
|
274
297
|
self._lazy = lazy
|
|
275
298
|
|
|
276
299
|
if immediate and lazy:
|
|
277
300
|
raise ValueError("An effect cannot be boht immediate and lazy")
|
|
278
301
|
|
|
302
|
+
# Register explicit dependencies immediately upon initialization
|
|
303
|
+
if deps is not None:
|
|
304
|
+
self.deps = {dep: dep.last_change for dep in deps}
|
|
305
|
+
for dep in deps:
|
|
306
|
+
dep.add_obs(self)
|
|
307
|
+
|
|
279
308
|
rc = REACTIVE_CONTEXT.get()
|
|
280
309
|
if rc.scope is not None:
|
|
281
310
|
rc.scope.register_effect(self)
|
|
@@ -291,6 +320,7 @@ class Effect:
|
|
|
291
320
|
if self.cleanup_fn:
|
|
292
321
|
self.cleanup_fn()
|
|
293
322
|
|
|
323
|
+
@override
|
|
294
324
|
def dispose(self):
|
|
295
325
|
self.unschedule()
|
|
296
326
|
for child in self.children.copy():
|
|
@@ -353,15 +383,24 @@ class Effect:
|
|
|
353
383
|
return
|
|
354
384
|
raise exc
|
|
355
385
|
|
|
356
|
-
def _apply_scope_results(
|
|
386
|
+
def _apply_scope_results(
|
|
387
|
+
self,
|
|
388
|
+
scope: "Scope",
|
|
389
|
+
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None,
|
|
390
|
+
) -> None:
|
|
391
|
+
# Apply captured last_change values at the end for explicit deps
|
|
392
|
+
if self.explicit_deps:
|
|
393
|
+
assert captured_last_changes is not None
|
|
394
|
+
for dep, last_change in captured_last_changes.items():
|
|
395
|
+
self.deps[dep] = last_change
|
|
396
|
+
return
|
|
397
|
+
|
|
357
398
|
self.children = scope.effects
|
|
358
399
|
for child in self.children:
|
|
359
400
|
child.parent = self
|
|
360
401
|
|
|
361
402
|
prev_deps = set(self.deps)
|
|
362
|
-
if self.
|
|
363
|
-
self.deps = {dep: dep.last_change for dep in self._explicit_deps}
|
|
364
|
-
else:
|
|
403
|
+
if not self.explicit_deps:
|
|
365
404
|
self.deps = scope.deps
|
|
366
405
|
new_deps = set(self.deps)
|
|
367
406
|
add_deps = new_deps - prev_deps
|
|
@@ -379,8 +418,8 @@ class Effect:
|
|
|
379
418
|
|
|
380
419
|
def _copy_kwargs(self) -> dict[str, Any]:
|
|
381
420
|
deps = None
|
|
382
|
-
if self.
|
|
383
|
-
deps = list(self.
|
|
421
|
+
if self.explicit_deps:
|
|
422
|
+
deps = list(self.deps.keys())
|
|
384
423
|
return {
|
|
385
424
|
"fn": self.fn,
|
|
386
425
|
"name": self.name,
|
|
@@ -418,6 +457,10 @@ class Effect:
|
|
|
418
457
|
|
|
419
458
|
def _execute(self) -> None:
|
|
420
459
|
execution_epoch = epoch()
|
|
460
|
+
# Capture last_change for explicit deps before running
|
|
461
|
+
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None
|
|
462
|
+
if self.explicit_deps:
|
|
463
|
+
captured_last_changes = {dep: dep.last_change for dep in self.deps}
|
|
421
464
|
with Scope() as scope:
|
|
422
465
|
# Clear batch *before* running as we may update a signal that causes
|
|
423
466
|
# this effect to be rescheduled.
|
|
@@ -428,11 +471,13 @@ class Effect:
|
|
|
428
471
|
self.handle_error(e)
|
|
429
472
|
self.runs += 1
|
|
430
473
|
self.last_run = execution_epoch
|
|
431
|
-
self._apply_scope_results(scope)
|
|
474
|
+
self._apply_scope_results(scope, captured_last_changes)
|
|
432
475
|
|
|
433
476
|
|
|
434
477
|
class AsyncEffect(Effect):
|
|
435
|
-
|
|
478
|
+
fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
|
|
479
|
+
batch: None # pyright: ignore[reportIncompatibleVariableOverride]
|
|
480
|
+
_task: asyncio.Task[None] | None
|
|
436
481
|
|
|
437
482
|
def __init__(
|
|
438
483
|
self,
|
|
@@ -442,6 +487,8 @@ class AsyncEffect(Effect):
|
|
|
442
487
|
on_error: Callable[[Exception], None] | None = None,
|
|
443
488
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
444
489
|
):
|
|
490
|
+
# Track an async task when running async effects
|
|
491
|
+
self._task = None
|
|
445
492
|
super().__init__(
|
|
446
493
|
fn=fn, # pyright: ignore[reportArgumentType]
|
|
447
494
|
name=name,
|
|
@@ -450,12 +497,19 @@ class AsyncEffect(Effect):
|
|
|
450
497
|
on_error=on_error,
|
|
451
498
|
deps=deps,
|
|
452
499
|
)
|
|
453
|
-
# Track an async task when running async effects
|
|
454
|
-
self._task: asyncio.Task[Any] | None = None
|
|
455
500
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
501
|
+
@override
|
|
502
|
+
def schedule(self):
|
|
503
|
+
"""
|
|
504
|
+
Schedule the async effect. Unlike synchronous effects, async effects do not
|
|
505
|
+
go through batches, they cancel the previous run and create a new task
|
|
506
|
+
immediately..
|
|
507
|
+
"""
|
|
508
|
+
self.run()
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def is_scheduled(self) -> bool:
|
|
512
|
+
return self._task is not None
|
|
459
513
|
|
|
460
514
|
@override
|
|
461
515
|
def _copy_kwargs(self):
|
|
@@ -464,48 +518,91 @@ class AsyncEffect(Effect):
|
|
|
464
518
|
return kwargs
|
|
465
519
|
|
|
466
520
|
@override
|
|
467
|
-
def
|
|
521
|
+
def run(self) -> asyncio.Task[Any]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
522
|
+
"""
|
|
523
|
+
Run the async effect immediately, cancelling any previous run.
|
|
524
|
+
Returns the asyncio.Task.
|
|
525
|
+
"""
|
|
468
526
|
execution_epoch = epoch()
|
|
469
527
|
|
|
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
528
|
# Cancel any previous run still in flight
|
|
475
529
|
self.cancel()
|
|
530
|
+
this_task: asyncio.Task[None] | None = None
|
|
476
531
|
|
|
477
532
|
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
|
-
|
|
533
|
+
nonlocal execution_epoch, this_task
|
|
534
|
+
try:
|
|
535
|
+
# Perform cleanups in the new task
|
|
536
|
+
with Untrack():
|
|
537
|
+
try:
|
|
538
|
+
self._cleanup_before_run()
|
|
539
|
+
except Exception as e:
|
|
540
|
+
self.handle_error(e)
|
|
541
|
+
|
|
542
|
+
# Capture last_change for explicit deps before running
|
|
543
|
+
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = (
|
|
544
|
+
None
|
|
545
|
+
)
|
|
546
|
+
if self.explicit_deps:
|
|
547
|
+
captured_last_changes = {dep: dep.last_change for dep in self.deps}
|
|
548
|
+
|
|
549
|
+
with Scope() as scope:
|
|
550
|
+
try:
|
|
551
|
+
result = self.fn()
|
|
552
|
+
self.cleanup_fn = await maybe_await(result)
|
|
553
|
+
except asyncio.CancelledError:
|
|
554
|
+
# Re-raise so finally block executes to clear task reference
|
|
555
|
+
raise
|
|
556
|
+
except Exception as e:
|
|
557
|
+
self.handle_error(e)
|
|
558
|
+
self.runs += 1
|
|
559
|
+
self.last_run = execution_epoch
|
|
560
|
+
self._apply_scope_results(scope, captured_last_changes)
|
|
561
|
+
finally:
|
|
562
|
+
# Clear the task reference when it finishes
|
|
563
|
+
if self._task is this_task:
|
|
564
|
+
self._task = None
|
|
565
|
+
|
|
566
|
+
this_task = create_task(_runner(), name=f"effect:{self.name or 'unnamed'}")
|
|
567
|
+
self._task = this_task
|
|
568
|
+
return this_task
|
|
569
|
+
|
|
570
|
+
@override
|
|
571
|
+
async def __call__(self): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
572
|
+
await self.run()
|
|
497
573
|
|
|
498
574
|
def cancel(self) -> None:
|
|
499
|
-
|
|
500
|
-
if self._task
|
|
501
|
-
self._task
|
|
575
|
+
# No batch removal needed as AsyncEffect is not batched
|
|
576
|
+
if self._task:
|
|
577
|
+
t = self._task
|
|
578
|
+
self._task = None
|
|
579
|
+
if not t.cancelled():
|
|
580
|
+
t.cancel()
|
|
581
|
+
|
|
582
|
+
async def wait(self) -> None:
|
|
583
|
+
"""
|
|
584
|
+
Wait until the completion of the current task if it's already running,
|
|
585
|
+
or start a run if it's not running. In case of cancellation, awaits
|
|
586
|
+
the new task by recursively calling itself.
|
|
587
|
+
"""
|
|
588
|
+
while True:
|
|
589
|
+
try:
|
|
590
|
+
await (self._task or self.run())
|
|
591
|
+
return
|
|
592
|
+
except asyncio.CancelledError:
|
|
593
|
+
# If wait() itself is cancelled, propagate it
|
|
594
|
+
current_task = asyncio.current_task()
|
|
595
|
+
if current_task is not None and (
|
|
596
|
+
current_task.cancelling() > 0 or current_task.cancelled()
|
|
597
|
+
):
|
|
598
|
+
raise
|
|
599
|
+
# Effect task was cancelled, continue waiting for new task
|
|
600
|
+
continue
|
|
502
601
|
|
|
503
602
|
@override
|
|
504
603
|
def dispose(self):
|
|
505
604
|
# Run children cleanups first, then cancel in-flight task
|
|
506
|
-
self.
|
|
507
|
-
if self._task and not self._task.done():
|
|
508
|
-
self._task.cancel()
|
|
605
|
+
self.cancel()
|
|
509
606
|
for child in self.children.copy():
|
|
510
607
|
child.dispose()
|
|
511
608
|
if self.cleanup_fn:
|
|
@@ -579,11 +676,12 @@ class Batch:
|
|
|
579
676
|
exc_type: type[BaseException] | None,
|
|
580
677
|
exc_value: BaseException | None,
|
|
581
678
|
exc_traceback: Any,
|
|
582
|
-
):
|
|
679
|
+
) -> Literal[False]:
|
|
583
680
|
self.flush()
|
|
584
681
|
# Restore previous reactive context
|
|
585
682
|
if self._token:
|
|
586
683
|
REACTIVE_CONTEXT.reset(self._token)
|
|
684
|
+
return False
|
|
587
685
|
|
|
588
686
|
|
|
589
687
|
class GlobalBatch(Batch):
|
|
@@ -659,10 +757,11 @@ class Scope:
|
|
|
659
757
|
exc_type: type[BaseException] | None,
|
|
660
758
|
exc_value: BaseException | None,
|
|
661
759
|
exc_traceback: Any,
|
|
662
|
-
):
|
|
760
|
+
) -> Literal[False]:
|
|
663
761
|
# Restore previous reactive context
|
|
664
762
|
if self._token:
|
|
665
763
|
REACTIVE_CONTEXT.reset(self._token)
|
|
764
|
+
return False
|
|
666
765
|
|
|
667
766
|
|
|
668
767
|
class Untrack(Scope): ...
|
|
@@ -705,8 +804,9 @@ class ReactiveContext:
|
|
|
705
804
|
exc_type: type[BaseException] | None,
|
|
706
805
|
exc_value: BaseException | None,
|
|
707
806
|
exc_tb: Any,
|
|
708
|
-
):
|
|
807
|
+
) -> Literal[False]:
|
|
709
808
|
REACTIVE_CONTEXT.reset(self._tokens.pop())
|
|
809
|
+
return False
|
|
710
810
|
|
|
711
811
|
|
|
712
812
|
def epoch():
|
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):
|
pulse/vdom.py
CHANGED
|
@@ -23,6 +23,7 @@ from typing import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
from pulse.hooks.core import HookContext
|
|
26
|
+
from pulse.hooks.init import rewrite_init_blocks
|
|
26
27
|
|
|
27
28
|
# ============================================================================
|
|
28
29
|
# Core VDOM
|
|
@@ -291,7 +292,8 @@ def component(
|
|
|
291
292
|
fn: "Callable[P, Element] | None" = None, *, name: str | None = None
|
|
292
293
|
) -> "Component[P] | Callable[[Callable[P, Element]], Component[P]]":
|
|
293
294
|
def decorator(fn: Callable[P, Element]):
|
|
294
|
-
|
|
295
|
+
rewritten = rewrite_init_blocks(fn)
|
|
296
|
+
return Component(rewritten, name)
|
|
295
297
|
|
|
296
298
|
if fn is not None:
|
|
297
299
|
return decorator(fn)
|