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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.40
3
+ Version: 0.1.42
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
@@ -1,22 +1,22 @@
1
- pulse/__init__.py,sha256=P2CLvFxP8IPiUsy3Z-hZD-1tbsvhd4eVlMNYg5WOPjE,31709
2
- pulse/app.py,sha256=VwalJMondhPfhCaqXpEQH6vBezX7jmm9e24GZNr21M0,29726
3
- pulse/channel.py,sha256=DuD1mg_xWvkpAWSKZ-EtBYdUzJ8IuKH0fxdgGOvFXpg,13041
1
+ pulse/__init__.py,sha256=w7LVrYNiho18v9JyDQ8DZGdBYV4dZYtlEFiRi32ICiw,32166
2
+ pulse/app.py,sha256=cVEqazFcgSnmZSxqqz6HWk2QsI8rnKbO7Y_L88BcHSc,32082
3
+ pulse/channel.py,sha256=d9eLxgyB0P9UBVkPkXV7MHkC4LWED1Cq3GKsEu_SYy4,13056
4
4
  pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- pulse/cli/cmd.py,sha256=jA1kgw6Dibj8jC_amcRL4mCzfboszLTnVAQtJA3_G-4,14011
5
+ pulse/cli/cmd.py,sha256=UBT7OoqWRU-idLOKkA9TDN8m8ugi1gwRMiUJTUmkVfU,14853
6
6
  pulse/cli/dependencies.py,sha256=ZBqBAfMvMBQUvh4THdPDztTMQ_dyR52S1IuotP_eEZs,5623
7
7
  pulse/cli/folder_lock.py,sha256=kvUmZBg869lwCTIZFoge9dhorv8qPXHTWwVv_jQg1k8,3477
8
8
  pulse/cli/helpers.py,sha256=8bRlV3d7w3w-jHaFvFYt9Pzue6_CbKOq_Z3jBsBOeUk,8820
9
- pulse/cli/models.py,sha256=hRmIWmhXmGf2otzVm1do4Dm19rkWkmTwAA3Am3kw2tE,692
9
+ pulse/cli/models.py,sha256=NBV5byBDNoAQSk0vKwibLjoxuA85XBYIyOVJn64L8oU,858
10
10
  pulse/cli/packages.py,sha256=e7ycwwJfdmB4pzrai4DHos6-JzyUgmE4DCZp0BqjdeI,6792
11
- pulse/cli/processes.py,sha256=yJg2hHY2JggLkvQS9XrVwdl7reQhF333qxATPnPkmrU,5727
11
+ pulse/cli/processes.py,sha256=C1xU72oUanj-1Mkc9WmqESTsVUn_aUHG8URiPyRHSFM,7016
12
12
  pulse/cli/secrets.py,sha256=dNfQe6AzSYhZuWveesjCRHIbvaPd3-F9lEJ-kZA7ROw,921
13
- pulse/cli/uvicorn_log_config.py,sha256=Ip0iCeMUoY1ruv3Amf2SF84lW2DDpJFqdsLflZNxmeY,2407
13
+ pulse/cli/uvicorn_log_config.py,sha256=f7ikDc5foXh3TmFMrnfnW8yev48ZAdlo8F4F_aMVoVk,2391
14
14
  pulse/codegen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- pulse/codegen/codegen.py,sha256=RMc2NkldX0dmxRG59gdulCMEzywvHMo2akeiHQMTu4I,10681
15
+ pulse/codegen/codegen.py,sha256=lPhgl57Ag3ChEDdhFD7wUCaU6lFiTQ1cD_vpSUgMfKg,11093
16
16
  pulse/codegen/imports.py,sha256=13f0uzJsotw069aP_COUUPMuTXXhRKRwUzfxsSCq_6A,6070
17
17
  pulse/codegen/js.py,sha256=7MuiECSJ-DulSqKuMZ8z1q_d7e3AbK6MYiNTYALZCLA,881
18
18
  pulse/codegen/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- pulse/codegen/templates/layout.py,sha256=Et9e8zZ6xN5vhxlhwDVOFcdAP1Odl8oHz4eYO3iyhc4,4396
19
+ pulse/codegen/templates/layout.py,sha256=nmWPQcO9SRXc3mCCVLCmykreSF96TqQfdDY7dvUBxRg,4737
20
20
  pulse/codegen/templates/route.py,sha256=cwmNHYkuecZ5M986hmm6SxisIoVSc656dtpqAvPjMjM,7824
21
21
  pulse/codegen/templates/routes_ts.py,sha256=nPgKCvU0gzue2k6KlOL1TJgrBqqRLmyy7K_qKAI8zAE,1129
22
22
  pulse/codegen/utils.py,sha256=QoXcV-h-DLLmq_t03hDNUePS0fNnofUQLoR-TXzDFCY,539
@@ -24,20 +24,21 @@ pulse/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
24
24
  pulse/components/for_.py,sha256=LUyJEUlDM6b9oPjvUFgSsddxu6b6usF4BQdXe8FIiGI,1302
25
25
  pulse/components/if_.py,sha256=rQywsmdirNpkb-61ZEdF-tgzUh-37JWd4YFGblkzIdQ,1624
26
26
  pulse/components/react_router.py,sha256=TbRec-NVliUqrvAMeFXCrnDWV1rh6TGTPfRhqLuLubk,1129
27
- pulse/context.py,sha256=x_nCbCEUGygAdCZiTfko5uuYxVSAeCNhYa59zBq015M,1692
27
+ pulse/context.py,sha256=fMK6GdQY4q_3452v5DJli2f2_urVihnpzb-O-O9cJ1Q,1734
28
28
  pulse/cookies.py,sha256=c7ua1Lv6mNe1nYnA4SFVvewvRQAbYy9fN5G3Hr_Dr5c,5000
29
29
  pulse/css.py,sha256=-FyQQQ0EZI1Ins30qiF3l4z9yDb1V9qWuJKWxHcKGkw,3910
30
- pulse/decorators.py,sha256=8At1HQTFs9KG7nd83miGMe3KkhTBVGDviaqZaY62bHI,6651
30
+ pulse/decorators.py,sha256=hRfgb9XU1yizmtdhuBln_3Gy-Cz2Smo4rYvAqlURrLQ,9348
31
31
  pulse/env.py,sha256=p3XI8KG1ZCcXPD3LJP7fW8JPYfyvoYY5ENwae2o0PiA,2889
32
- pulse/form.py,sha256=M87QwG4KFOrI8Nba7BTDoJ_wZ1-jzJW7QN4JweYCpuM,9004
33
- pulse/helpers.py,sha256=q54JGen1lBIEGyLnNKvmW_7nnTFqFZBDMYXsoXvth7o,11628
32
+ pulse/form.py,sha256=P7W8guUdGbgqNOk8cSUCWuY6qWre0me6_fypv1qOvqw,8987
33
+ pulse/helpers.py,sha256=BBtf--LZxvfpwJU8p92QrZWtOKIWfB3DOiAtGxhet90,13232
34
34
  pulse/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
- pulse/hooks/core.py,sha256=Ksb-vSYbigL2GnUooxdLVUTfnT7cHOlYEKgKtrihvJo,7344
35
+ pulse/hooks/core.py,sha256=JTZbVxNOEs_GAeK6Bh6hemSTkgwZPtEi_wt55cvOdik,7381
36
36
  pulse/hooks/effects.py,sha256=CQvt5viAweGLSxaGGlWm155GlEQiwQnGussw7OfiCGc,2393
37
+ pulse/hooks/init.py,sha256=snTy3PJtkSnnKBrAjcNOJbem2896xJzHD0DHLVVeyAo,11924
37
38
  pulse/hooks/runtime.py,sha256=k5LZ8hnlNBMKOiEkQcAvs8BKwYxV6gwea2WCfju5K7Y,5106
38
39
  pulse/hooks/setup.py,sha256=GJLSE6hLBNKHR9aLhvsS6KXwpOXQiSx1V3E2IkGADWM,4461
39
40
  pulse/hooks/stable.py,sha256=uHEJ2E22r2kHx4uFjWjDepQ6OtPjLd7tT5ju-yKlkCU,1702
40
- pulse/hooks/states.py,sha256=WVQxOMkktyNG0nXQ75PMoNqFDDJ1nc0NSVV8UWUggxk,5427
41
+ pulse/hooks/states.py,sha256=e9vO8ig8vaw6guY711S6rlY9WXCjDYXsjiR7IvyKrXU,6708
41
42
  pulse/html/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
43
  pulse/html/elements.py,sha256=YHXkVpfMAC4-0o61fK-E0LGTOM3KMCtBfpHHAwLx7dw,23241
43
44
  pulse/html/events.py,sha256=SiZxaQV340hc5YGoKWXC5uCmbLsuijuEgnQz1hmdqYg,14700
@@ -45,27 +46,32 @@ pulse/html/props.py,sha256=XatI6N4Hyef3MAql7jCxCIm6iuusgUXKkwHwIGm_dcc,26646
45
46
  pulse/html/svg.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
47
  pulse/html/tags.py,sha256=dyG4BY9qthBbO-ihcy9F8mLY6WqQxKFXfpqNYcSMKN0,5182
47
48
  pulse/html/tags.pyi,sha256=I8dFoft9w4RvneZ3li1weAdijY1krj9jfO_p2SU6e04,13953
48
- pulse/messages.py,sha256=Vz6pXUcBlQxHVEzP8jtA4ZBwn0P30oJzU07lzESP2JI,3625
49
- pulse/middleware.py,sha256=aMcfkQ2XUT-SMFNa51GYlQp5tnXQwkfbcGlqO4DGcKk,7874
50
- pulse/plugin.py,sha256=T1HLucOJekRfWMGF17arI3z7qfH-rBw_zPOQEV8v2mw,640
51
- pulse/proxy.py,sha256=XR2jV5NTLTdiYuJLMBWx2_aMB7Odoa63XQRHSOgA7jM,2709
49
+ pulse/messages.py,sha256=Z7iYkOacwca5tU1UgZHt96iojT5Hd7VDc0PkEcru_2Q,3619
50
+ pulse/middleware.py,sha256=9uyAhVUEGMSwqWC3WXqs7x5JMMNEcSTTu3g7DjsR8w8,9812
51
+ pulse/plugin.py,sha256=RfGl6Vtr7VRHb8bp4Ob4dOX9dVzvc4Riu7HWnStMPpk,580
52
+ pulse/proxy.py,sha256=zh4v5lmYNg5IBE_xdHHmGPwbMQNSXb2npeLXvw_O1Oc,6591
52
53
  pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
- pulse/query.py,sha256=u0KVFNt0d36sfmoxpQXvJ8DjctIHZaM_szlEonRiyRg,12849
54
- pulse/react_component.py,sha256=Rw1J6cHOX8-K3BnkswVOu2COgneVvRz1OYmyXkX17RM,25993
55
- pulse/reactive.py,sha256=MPIlG2zdYT2vQUWbi5EYbxezA3lBqf1oe1hMr20sQT4,18239
54
+ pulse/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
+ pulse/queries/common.py,sha256=2_11SEOFrDbe_ULNSA9EWeOkvz-xym5hCuX4bW455t0,556
56
+ pulse/queries/mutation.py,sha256=_0-o2g2yux52hTsRLGuWwFUdGrBx9YJqi67oBw9iNcc,4209
57
+ pulse/queries/query.py,sha256=WxBEaEjtzDGWWKpSi8-xl9xBvPvREYH1Tonl_lOY-VQ,7347
58
+ pulse/queries/query_observer.py,sha256=Wd3pk5OsZB_ze6DnpOkASv4Ny5EuSbwUlUEPXIXxSgk,10229
59
+ pulse/queries/store.py,sha256=ylSCOHiXp8vEyEWc5Et8zLWkyHj5OQYKOVfs0OehpX8,1465
60
+ pulse/react_component.py,sha256=hPibKBEkVdpBKNSpMQ6bZ-7GnJQcNQwcw2SvfY1chHA,26026
61
+ pulse/reactive.py,sha256=cKZDafbUQFdnNRAxI71THnsFZEbWZ5mU06pMuP6spo8,21187
56
62
  pulse/reactive_extensions.py,sha256=gTLkQ0urwANjWNHWMkg-P9zvpevHCnNKd5BSM8G0pno,31521
57
- pulse/render_session.py,sha256=Z5_01H-J71k_Y7zuS3tFpi4cycl6AaG8fne8q0ovhvM,14467
63
+ pulse/render_session.py,sha256=kqLfZ9AxCrB2mIJqegATL1KA7CI-LZSBQwRYr7Uxo9g,14581
58
64
  pulse/renderer.py,sha256=dJiX9VeHr9kC1UBw5oaKB8Mv-3OCMGTrHiKgLJ5FL50,16759
59
65
  pulse/request.py,sha256=sPsSRWi5KvReSPBLIs_kzqomn1wlRk1BTLZ5s0chQr4,4979
60
- pulse/routing.py,sha256=o3xmWpoMgJMkI1W7nFJpDkCT30ejTG2MnCV42OwgLaI,12492
66
+ pulse/routing.py,sha256=RlrGHyK4F28_zUHMYNeKp4pNbvqrt4GY4t5xNdhzunI,13926
61
67
  pulse/serializer.py,sha256=8RAITNoSNm5-U38elHpWmkBpcM_rxZFMCluJSfldfk4,5420
62
- pulse/state.py,sha256=y9Z-KBYQVd63fb1wF8gQ4KilK7c2ElYLf6U-miWteM8,10560
68
+ pulse/state.py,sha256=mytXlQjmLIBjB2XDgCg9E1fHCcyoNQ02cBqZ_vldxuc,10636
63
69
  pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
- pulse/types/event_handler.py,sha256=OF7sOgYBb6iUs59RH1vQIH7aOrGPfs3nAaF7how-4PQ,1658
65
- pulse/user_session.py,sha256=kCZtQpYZe2keDXzusd6jsjjw075am0dXrb25jKLg5JU,7578
66
- pulse/vdom.py,sha256=KTNBh2dVvDy9eXRzhneBJgk7F35MyWec8R_puQ4tSRY,12420
70
+ pulse/types/event_handler.py,sha256=tfKa6OEA5XvzuYbllQZJ03ooN7rGSYOtaPBstSL4OLU,1642
71
+ pulse/user_session.py,sha256=Nn9ZZha1Rruw31OSoK14QaEL0erGVFbryFhJYrtMZsQ,7599
72
+ pulse/vdom.py,sha256=1UAjOYSmpdZeSVELqejh47Jer4mA73T_q2HtAogOphs,12514
67
73
  pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
68
- pulse_framework-0.1.40.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
69
- pulse_framework-0.1.40.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
70
- pulse_framework-0.1.40.dist-info/METADATA,sha256=j481f-W5TtovwCzdxBioeaG-rTHLmY7uhMpKc4RP_WU,580
71
- pulse_framework-0.1.40.dist-info/RECORD,,
74
+ pulse_framework-0.1.42.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
75
+ pulse_framework-0.1.42.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
76
+ pulse_framework-0.1.42.dist-info/METADATA,sha256=Pyy5qb02JHkAFwGpy6m8SkrlPH_mmL6BPxDfvQ41ly4,580
77
+ pulse_framework-0.1.42.dist-info/RECORD,,
pulse/query.py DELETED
@@ -1,408 +0,0 @@
1
- import asyncio
2
- from collections.abc import Awaitable, Callable, Coroutine
3
- from typing import (
4
- Any,
5
- Generic,
6
- TypeVar,
7
- TypeVarTuple,
8
- cast,
9
- override,
10
- )
11
-
12
- from pulse.helpers import call_flexible, maybe_await
13
- from pulse.reactive import AsyncEffect, Computed, EffectCleanup, Signal
14
- from pulse.state import InitializableProperty, State
15
-
16
- T = TypeVar("T")
17
- TState = TypeVar("TState", bound="State")
18
- Args = TypeVarTuple("Args")
19
- R = TypeVar("R")
20
-
21
- # Type alias matching AsyncEffectFn from reactive.py
22
- AsyncEffectFn = Callable[[], Coroutine[Any, Any, EffectCleanup | None]]
23
-
24
-
25
- class QueryAsyncEffect(AsyncEffect):
26
- """
27
- Specialized AsyncEffect for queries that synchronously clears query data
28
- when rescheduled (if keep_previous_data is False).
29
- """
30
-
31
- _result: "QueryResult[Any]"
32
- _keep_previous_data: bool
33
-
34
- def __init__(
35
- self,
36
- fn: AsyncEffectFn,
37
- result: "QueryResult[Any]",
38
- keep_previous_data: bool,
39
- name: str | None = None,
40
- deps: list[Signal[Any] | Computed[Any]] | None = None,
41
- ):
42
- super().__init__(fn, name=name, deps=deps)
43
- self._result = result
44
- self._keep_previous_data = keep_previous_data
45
-
46
- @override
47
- def push_change(self):
48
- # Synchronously clear data before scheduling the effect to run.
49
- # This ensures renders see the loading state immediately, not stale data.
50
- self._result._set_loading(clear_data=not self._keep_previous_data) # pyright: ignore[reportPrivateUsage]
51
- super().push_change()
52
-
53
-
54
- class QueryResult(Generic[T]):
55
- def __init__(self, initial_data: T | None = None):
56
- # print("[QueryResult] initialize")
57
- self._is_loading: Signal[bool] = Signal(True, name="query.is_loading")
58
- self._is_error: Signal[bool] = Signal(False, name="query.is_error")
59
- self._error: Signal[Exception | None] = Signal(None, name="query.error")
60
- # Store initial data so we can preserve non-None semantics when requested
61
- self._initial_data: T | None = initial_data
62
- self._data: Signal[T | None] = Signal(initial_data, name="query.data")
63
- # Tracks whether at least one load cycle completed (success or error)
64
- self._has_loaded: Signal[bool] = Signal(False, name="query.has_loaded")
65
- # Effect driving this query (attached by QueryProperty)
66
- self._effect: AsyncEffect | None = None
67
-
68
- @property
69
- def is_loading(self) -> bool:
70
- # print(f"[QueryResult] Accessing is_loading = {self._is_loading.read()}")
71
- return self._is_loading.read()
72
-
73
- @property
74
- def is_error(self) -> bool:
75
- # print(f"[QueryResult] Accessing is_error = {self._is_error.read()}")
76
- return self._is_error.read()
77
-
78
- @property
79
- def error(self) -> Exception | None:
80
- # print(f"[QueryResult] Accessing error = {self._error.read()}")
81
- return self._error.read()
82
-
83
- @property
84
- def data(self) -> T | None:
85
- # print(f"[QueryResult] Accessing data = {self._data.read()}")
86
- return self._data.read()
87
-
88
- @property
89
- def has_loaded(self) -> bool:
90
- return self._has_loaded.read()
91
-
92
- def attach_effect(self, effect: AsyncEffect) -> None:
93
- self._effect = effect
94
-
95
- def refetch(self) -> None:
96
- if self._effect is None:
97
- return
98
- self._effect.cancel()
99
- self._effect.run()
100
-
101
- def dispose(self) -> None:
102
- if self._effect is None:
103
- return
104
- self._effect.dispose()
105
-
106
- # Internal setters used by the query machinery
107
- def _set_loading(self, *, clear_data: bool = False):
108
- # print("[QueryResult] set loading=True")
109
- self._is_loading.write(True)
110
- self._is_error.write(False)
111
- self._error.write(None)
112
- if clear_data:
113
- # If there was an explicit initial value, reset to it; otherwise clear
114
- self._data.write(self._initial_data)
115
-
116
- def _set_success(self, data: T):
117
- # print(f"[QueryResult] set success data={data!r}")
118
- self._data.write(data)
119
- self._is_loading.write(False)
120
- self._is_error.write(False)
121
- self._error.write(None)
122
- self._has_loaded.write(True)
123
-
124
- def _set_error(self, err: Exception):
125
- # print(f"[QueryResult] set error err={err!r}")
126
- self._error.write(err)
127
- self._is_loading.write(False)
128
- self._is_error.write(True)
129
- self._has_loaded.write(True)
130
-
131
- # Public mutator useful for optimistic updates; does not change loading/error flags
132
- def set_data(self, data: T):
133
- self._data.write(data)
134
-
135
- # Public mutator to set initial data before the first load completes.
136
- # If called after the first load, it is ignored.
137
- def set_initial_data(self, data: T):
138
- if self._has_loaded.read():
139
- return
140
- self._initial_data = data
141
- self._data.write(data)
142
-
143
- # Public helpers mirroring internal transitions
144
- def set_loading(self, *, clear_data: bool = False) -> None:
145
- self._set_loading(clear_data=clear_data)
146
-
147
- def set_success(self, data: T) -> None:
148
- self._set_success(data)
149
-
150
- def set_error(self, err: Exception) -> None:
151
- self._set_error(err)
152
-
153
-
154
- OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
155
- OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
156
-
157
-
158
- class QueryProperty(Generic[T, TState], InitializableProperty):
159
- """
160
- Descriptor for state-bound queries.
161
-
162
- Usage:
163
- class S(ps.State):
164
- @ps.query()
165
- async def user(self) -> User: ...
166
-
167
- @user.key
168
- def _user_key(self):
169
- return ("user", self.user_id)
170
- """
171
-
172
- name: str
173
- _fetch_fn: "Callable[[TState], Awaitable[T]]"
174
- _keep_alive: bool
175
- _keep_previous_data: bool
176
- _initial: T | None
177
- _key_fn: Callable[[TState], tuple[Any, ...]] | None
178
- _initial_data_fn: Callable[[TState], T] | None
179
- # Not using OnSuccessFn and OnErrorFn since unions of callables are not well
180
- # supported in the type system. We just need to be careful to use
181
- # call_flexible to invoke these functions.
182
- _on_success_fn: Callable[[TState, Exception], Any] | None
183
- _on_error_fn: Callable[[TState, T], Any] | None
184
-
185
- _priv_query: str
186
- _priv_effect: str
187
- _priv_key_comp: str
188
- _priv_initial_fn: str
189
- _priv_initial_applied: str
190
-
191
- def __init__(
192
- self,
193
- name: str,
194
- fetch_fn: "Callable[[TState], Awaitable[T]]",
195
- keep_alive: bool = False,
196
- keep_previous_data: bool = False,
197
- initial: T | None = None,
198
- ):
199
- self.name = name
200
- self._fetch_fn = fetch_fn
201
- self._key_fn = None
202
- self._initial_data_fn = None
203
- # Single handlers; error if set more than once
204
- self._on_success_fn = None
205
- self._on_error_fn = None
206
- self._keep_alive = keep_alive
207
- self._keep_previous_data = keep_previous_data
208
- self._initial = initial
209
- self._priv_query = f"__query_{name}"
210
- self._priv_effect = f"__query_effect_{name}"
211
- self._priv_key_comp = f"__query_key_{name}"
212
- self._priv_initial_fn = f"__query_initial_fn_{name}"
213
- self._priv_initial_applied = f"__query_initial_applied_{name}"
214
-
215
- # Decorator to attach a key function
216
- def key(self, fn: Callable[[TState], tuple[Any, ...]]):
217
- if self._key_fn is not None:
218
- raise RuntimeError(
219
- f"Duplicate key() decorator for query '{self.name}'. Only one is allowed."
220
- )
221
- self._key_fn = fn
222
- return fn
223
-
224
- # Decorator to attach a function providing initial data
225
- def initial_data(self, fn: Callable[[TState], T]):
226
- if self._initial_data_fn is not None:
227
- raise RuntimeError(
228
- f"Duplicate initial_data() decorator for query '{self.name}'. Only one is allowed."
229
- )
230
- self._initial_data_fn = fn
231
- return fn
232
-
233
- # Decorator to attach an on-success handler (sync or async)
234
- def on_success(self, fn: OnSuccessFn[TState, T]):
235
- if self._on_success_fn is not None:
236
- raise RuntimeError(
237
- f"Duplicate on_success() decorator for query '{self.name}'. Only one is allowed."
238
- )
239
- self._on_success_fn = fn # pyright: ignore[reportAttributeAccessIssue]
240
- return fn
241
-
242
- # Decorator to attach an on-error handler (sync or async)
243
- def on_error(self, fn: OnErrorFn[TState]):
244
- if self._on_error_fn is not None:
245
- raise RuntimeError(
246
- f"Duplicate on_error() decorator for query '{self.name}'. Only one is allowed."
247
- )
248
- self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
249
- return fn
250
-
251
- @override
252
- def initialize(self, state: Any, name: str) -> QueryResult[T]:
253
- # Return cached query instance if present
254
- query: QueryResult[T] | None = getattr(state, self._priv_query, None)
255
- if query:
256
- # print(f"[QueryProperty:{self.name}] return cached StateQuery")
257
- return query
258
-
259
- # key_fn being None means auto-tracked mode
260
-
261
- # Bind methods to this instance
262
- bound_fetch = bind_state(state, self._fetch_fn)
263
- bound_on_success = (
264
- bind_state(state, self._on_success_fn) if self._on_success_fn else None
265
- )
266
- bound_on_error = (
267
- bind_state(state, self._on_error_fn) if self._on_error_fn else None
268
- )
269
- bound_initial_data = (
270
- bind_state(state, self._initial_data_fn) if self._initial_data_fn else None
271
- )
272
- # print(f"[QueryProperty:{self.name}] bound fetch and key/handlers")
273
-
274
- # Defer evaluating initial_data provider until after user __init__ by
275
- # storing it on the instance and marking it unapplied. Use constructor
276
- # `initial` as the initial visible value for now.
277
- setattr(state, self._priv_initial_fn, bound_initial_data)
278
- setattr(state, self._priv_initial_applied, False)
279
- initial_value: T | None = self._initial
280
-
281
- result = QueryResult[T](initial_data=initial_value)
282
-
283
- key_computed: Computed[tuple[Any, ...]] | None = None
284
- if self._key_fn:
285
- bound_key_fn = bind_state(state, self._key_fn)
286
- key_computed = Computed(bound_key_fn, name=f"query.key.{self.name}")
287
- setattr(state, self._priv_key_comp, key_computed)
288
-
289
- inflight_key: tuple[Any, ...] | None = None
290
-
291
- async def run_effect():
292
- # print(f"[QueryProperty:{self.name}] effect RUN")
293
- # In key mode, deduplicate same-key concurrent reruns
294
- if key_computed:
295
- key = key_computed()
296
-
297
- nonlocal inflight_key
298
- # De-duplicate same-key concurrent reruns
299
- if inflight_key == key:
300
- return None
301
- inflight_key = key
302
-
303
- # Set loading immediately; optionally clear previous data
304
- result.set_loading(clear_data=not self._keep_previous_data)
305
-
306
- try:
307
- data = await bound_fetch()
308
- except asyncio.CancelledError:
309
- # Cancellation is expected during reruns; swallow
310
- return None
311
- except Exception as e:
312
- result.set_error(e)
313
- # Invoke error handler if provided
314
- if bound_on_error:
315
- await maybe_await(call_flexible(bound_on_error, e))
316
- else:
317
- result.set_success(data)
318
- # Invoke success handler if provided
319
- if bound_on_success:
320
- await maybe_await(call_flexible(bound_on_success, data))
321
- finally:
322
- inflight_key = None
323
-
324
- # In key mode, depend only on key via explicit deps
325
- if key_computed is not None:
326
- effect = QueryAsyncEffect(
327
- run_effect,
328
- result=result,
329
- keep_previous_data=self._keep_previous_data,
330
- name=f"query.effect.{self.name}",
331
- deps=[key_computed],
332
- )
333
- else:
334
- effect = QueryAsyncEffect(
335
- run_effect,
336
- result=result,
337
- keep_previous_data=self._keep_previous_data,
338
- name=f"query.effect.{self.name}",
339
- )
340
- # print(f"[QueryProperty:{self.name}] created Effect name={effect.name}")
341
-
342
- # Expose the effect on the instance so State.effects() sees it
343
- setattr(state, self._priv_effect, effect)
344
- # Attach effect to result and expose result directly
345
- result.attach_effect(effect)
346
- setattr(state, self._priv_query, result)
347
-
348
- # if not self.keep_alive:
349
-
350
- # def on_obs(count: int):
351
- # if count == 0:
352
- # # print("[QueryProperty] Disposing of effect due to no observers")
353
- # effect.dispose()
354
-
355
- # Stop when no one observes key or data
356
- # result._data.on_observer_change(on_obs)
357
- # result._is_error.on_observer_change(on_obs)
358
- # result._is_loading.on_observer_change(on_obs)
359
-
360
- return result
361
-
362
- def __get__(self, obj: Any, objtype: Any = None) -> QueryResult[T]:
363
- if obj is None:
364
- return self # pyright: ignore[reportReturnType]
365
- query = self.initialize(obj, self.name)
366
- # Apply initial_data provider once, after state __init__, before first load
367
- try:
368
- applied = bool(getattr(obj, self._priv_initial_applied, False))
369
- except Exception:
370
- applied = True # fail safe: do not attempt if attribute missing
371
- if not applied and not query.has_loaded:
372
- bound_initial = getattr(obj, self._priv_initial_fn, None)
373
- if callable(bound_initial):
374
- try:
375
- value = bound_initial()
376
- if value is not None:
377
- query.set_initial_data(value) # pyright: ignore[reportArgumentType]
378
- except Exception:
379
- pass
380
- try:
381
- setattr(obj, self._priv_initial_applied, True)
382
- except Exception:
383
- pass
384
- return query
385
-
386
-
387
- class QueryResultWithInitial(QueryResult[T]):
388
- @property
389
- @override
390
- def data(self) -> T:
391
- return cast(T, super().data)
392
-
393
- @property
394
- @override
395
- def has_loaded(self) -> bool: # mirror base for completeness
396
- return super().has_loaded
397
-
398
-
399
- class QueryPropertyWithInitial(QueryProperty[T, TState]):
400
- @override
401
- def __get__(self, obj: Any, objtype: Any = None) -> QueryResultWithInitial[T]:
402
- # Reuse base initialization but narrow the return type for type-checkers
403
- return cast(QueryResultWithInitial[T], super().__get__(obj, objtype))
404
-
405
-
406
- def bind_state(state: TState, fn: Callable[[TState, *Args], R]) -> Callable[[*Args], R]:
407
- "Type-safe helper to bind a method to a state"
408
- return fn.__get__(state, state.__class__)