pulse-framework 0.1.70__tar.gz → 0.1.71__tar.gz

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.
Files changed (127) hide show
  1. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/__init__.py +4 -0
  4. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/client.py +64 -56
  5. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/common.py +54 -3
  6. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/infinite_query.py +47 -16
  7. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/query.py +28 -10
  8. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/store.py +13 -11
  9. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/README.md +0 -0
  10. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/_examples.py +0 -0
  11. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/app.py +0 -0
  12. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/channel.py +0 -0
  13. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/__init__.py +0 -0
  14. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/cmd.py +0 -0
  15. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/dependencies.py +0 -0
  16. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/folder_lock.py +0 -0
  17. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/helpers.py +0 -0
  18. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/logging.py +0 -0
  19. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/models.py +0 -0
  20. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/packages.py +0 -0
  21. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/processes.py +0 -0
  22. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/secrets.py +0 -0
  23. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cli/uvicorn_log_config.py +0 -0
  24. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/code_analysis.py +0 -0
  25. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/__init__.py +0 -0
  26. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/codegen.py +0 -0
  27. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/templates/__init__.py +0 -0
  28. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/templates/layout.py +0 -0
  29. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/templates/route.py +0 -0
  30. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/templates/routes_ts.py +0 -0
  31. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/codegen/utils.py +0 -0
  32. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/component.py +0 -0
  33. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/components/__init__.py +0 -0
  34. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/components/for_.py +0 -0
  35. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/components/if_.py +0 -0
  36. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/components/react_router.py +0 -0
  37. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/context.py +0 -0
  38. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/cookies.py +0 -0
  39. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/decorators.py +0 -0
  40. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/__init__.py +0 -0
  41. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/elements.py +0 -0
  42. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/events.py +0 -0
  43. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/props.py +0 -0
  44. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/svg.py +0 -0
  45. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/tags.py +0 -0
  46. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/dom/tags.pyi +0 -0
  47. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/env.py +0 -0
  48. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/forms.py +0 -0
  49. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/helpers.py +0 -0
  50. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/__init__.py +0 -0
  51. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/core.py +0 -0
  52. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/effects.py +0 -0
  53. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/init.py +0 -0
  54. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/runtime.py +0 -0
  55. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/setup.py +0 -0
  56. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/stable.py +0 -0
  57. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/hooks/state.py +0 -0
  58. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/__init__.py +0 -0
  59. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/__init__.pyi +0 -0
  60. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/_types.py +0 -0
  61. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/array.py +0 -0
  62. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/console.py +0 -0
  63. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/date.py +0 -0
  64. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/document.py +0 -0
  65. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/error.py +0 -0
  66. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/json.py +0 -0
  67. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/map.py +0 -0
  68. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/math.py +0 -0
  69. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/navigator.py +0 -0
  70. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/number.py +0 -0
  71. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/obj.py +0 -0
  72. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/object.py +0 -0
  73. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/promise.py +0 -0
  74. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/pulse.py +0 -0
  75. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/react.py +0 -0
  76. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/regexp.py +0 -0
  77. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/set.py +0 -0
  78. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/string.py +0 -0
  79. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/weakmap.py +0 -0
  80. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/weakset.py +0 -0
  81. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/js/window.py +0 -0
  82. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/messages.py +0 -0
  83. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/middleware.py +0 -0
  84. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/plugin.py +0 -0
  85. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/proxy.py +0 -0
  86. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/py.typed +0 -0
  87. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/__init__.py +0 -0
  88. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/effect.py +0 -0
  89. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/mutation.py +0 -0
  90. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/queries/protocol.py +0 -0
  91. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/react_component.py +0 -0
  92. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/reactive.py +0 -0
  93. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/reactive_extensions.py +0 -0
  94. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/render_session.py +0 -0
  95. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/renderer.py +0 -0
  96. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/request.py +0 -0
  97. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/requirements.py +0 -0
  98. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/routing.py +0 -0
  99. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/scheduling.py +0 -0
  100. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/serializer.py +0 -0
  101. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/state.py +0 -0
  102. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/test_helpers.py +0 -0
  103. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/__init__.py +0 -0
  104. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/assets.py +0 -0
  105. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/builtins.py +0 -0
  106. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/dynamic_import.py +0 -0
  107. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/emit_context.py +0 -0
  108. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/errors.py +0 -0
  109. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/function.py +0 -0
  110. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/id.py +0 -0
  111. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/imports.py +0 -0
  112. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/js_module.py +0 -0
  113. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/__init__.py +0 -0
  114. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/asyncio.py +0 -0
  115. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/json.py +0 -0
  116. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/math.py +0 -0
  117. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  118. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  119. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/modules/typing.py +0 -0
  120. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/nodes.py +0 -0
  121. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/py_module.py +0 -0
  122. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/transpiler.py +0 -0
  123. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/transpiler/vdom.py +0 -0
  124. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/types/__init__.py +0 -0
  125. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/types/event_handler.py +0 -0
  126. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/user_session.py +0 -0
  127. {pulse_framework-0.1.70 → pulse_framework-0.1.71}/src/pulse/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.70
3
+ Version: 0.1.71
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.128.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.70"
3
+ version = "0.1.71"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1328,8 +1328,12 @@ from pulse.queries.client import queries as queries
1328
1328
  from pulse.queries.common import ActionError as ActionError
1329
1329
  from pulse.queries.common import ActionResult as ActionResult
1330
1330
  from pulse.queries.common import ActionSuccess as ActionSuccess
1331
+ from pulse.queries.common import Key as Key
1331
1332
  from pulse.queries.common import QueryKey as QueryKey
1333
+ from pulse.queries.common import QueryKeys as QueryKeys
1332
1334
  from pulse.queries.common import QueryStatus as QueryStatus
1335
+ from pulse.queries.common import keys as keys
1336
+ from pulse.queries.common import normalize_key as normalize_key
1333
1337
  from pulse.queries.infinite_query import infinite_query as infinite_query
1334
1338
  from pulse.queries.mutation import mutation as mutation
1335
1339
  from pulse.queries.protocol import QueryResult as QueryResult
@@ -4,7 +4,7 @@ from typing import Any, TypeVar, overload
4
4
 
5
5
  from pulse.context import PulseContext
6
6
  from pulse.helpers import MISSING
7
- from pulse.queries.common import ActionResult, QueryKey
7
+ from pulse.queries.common import ActionResult, Key, QueryKey, QueryKeys, normalize_key
8
8
  from pulse.queries.infinite_query import InfiniteQuery, Page
9
9
  from pulse.queries.query import KeyedQuery
10
10
  from pulse.queries.store import QueryStore
@@ -13,34 +13,32 @@ T = TypeVar("T")
13
13
 
14
14
  # Query filter types
15
15
  QueryFilter = (
16
- QueryKey # exact key match
17
- | list[QueryKey] # explicit list of keys
18
- | Callable[[QueryKey], bool] # predicate function
16
+ QueryKey # exact key match (tuple or list)
17
+ | QueryKeys # explicit set of keys
18
+ | Callable[[Key], bool] # predicate function
19
19
  )
20
20
 
21
21
 
22
22
  def _normalize_filter(
23
23
  filter: QueryFilter | None,
24
- ) -> Callable[[QueryKey], bool] | None:
25
- """Convert any QueryFilter to a predicate function."""
24
+ ) -> tuple[Key | None, Callable[[Key], bool] | None]:
25
+ """Return normalized exact key (if any) and a predicate for filtering."""
26
26
  if filter is None:
27
- return None
28
- if isinstance(filter, tuple):
29
- # Exact key match
30
- exact_key = filter
31
- return lambda k: k == exact_key
32
- if isinstance(filter, list):
33
- # List of keys
34
- key_set = set(filter)
35
- return lambda k: k in key_set
36
- # Already a callable predicate
37
- return filter
38
-
39
-
40
- def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
27
+ return None, None
28
+ if callable(filter):
29
+ return None, filter
30
+ if isinstance(filter, QueryKeys):
31
+ key_set = set(filter.keys)
32
+ return None, lambda k: k in key_set
33
+ exact_key = normalize_key(filter)
34
+ return exact_key, lambda k: k == exact_key
35
+
36
+
37
+ def _prefix_filter(prefix: QueryKey) -> Callable[[Key], bool]:
41
38
  """Create a predicate that matches keys starting with the given prefix."""
42
- prefix_len = len(prefix)
43
- return lambda k: len(k) >= prefix_len and k[:prefix_len] == prefix
39
+ normalized = normalize_key(prefix)
40
+ prefix_len = len(normalized)
41
+ return lambda k: len(k) >= prefix_len and k[:prefix_len] == normalized
44
42
 
45
43
 
46
44
  class QueryClient:
@@ -120,7 +118,7 @@ class QueryClient:
120
118
  Get all queries matching the filter.
121
119
 
122
120
  Args:
123
- filter: Optional filter - can be an exact key, list of keys, or predicate.
121
+ filter: Optional filter - exact key, QueryKeys, or predicate.
124
122
  If None, returns all queries.
125
123
  include_infinite: Whether to include infinite queries (default True).
126
124
 
@@ -128,9 +126,16 @@ class QueryClient:
128
126
  List of matching Query or InfiniteQuery instances.
129
127
  """
130
128
  store = self._get_store()
131
- predicate = _normalize_filter(filter)
129
+ exact_key, predicate = _normalize_filter(filter)
132
130
  results: list[KeyedQuery[Any] | InfiniteQuery[Any, Any]] = []
133
131
 
132
+ if exact_key is not None:
133
+ if include_infinite:
134
+ entry = store.get_any(exact_key)
135
+ else:
136
+ entry = store.get(exact_key)
137
+ return [entry] if entry is not None else []
138
+
134
139
  for key, entry in store.items():
135
140
  if predicate is not None and not predicate(key):
136
141
  continue
@@ -144,16 +149,20 @@ class QueryClient:
144
149
  """Get all regular queries matching the filter.
145
150
 
146
151
  Args:
147
- filter: Optional filter - exact key, list of keys, or predicate.
152
+ filter: Optional filter - exact key, QueryKeys, or predicate.
148
153
  If None, returns all regular queries.
149
154
 
150
155
  Returns:
151
156
  List of matching KeyedQuery instances (excludes infinite queries).
152
157
  """
153
158
  store = self._get_store()
154
- predicate = _normalize_filter(filter)
159
+ exact_key, predicate = _normalize_filter(filter)
155
160
  results: list[KeyedQuery[Any]] = []
156
161
 
162
+ if exact_key is not None:
163
+ entry = store.get(exact_key)
164
+ return [entry] if entry is not None else []
165
+
157
166
  for key, entry in store.items():
158
167
  if isinstance(entry, InfiniteQuery):
159
168
  continue
@@ -169,16 +178,20 @@ class QueryClient:
169
178
  """Get all infinite queries matching the filter.
170
179
 
171
180
  Args:
172
- filter: Optional filter - exact key, list of keys, or predicate.
181
+ filter: Optional filter - exact key, QueryKeys, or predicate.
173
182
  If None, returns all infinite queries.
174
183
 
175
184
  Returns:
176
185
  List of matching InfiniteQuery instances.
177
186
  """
178
187
  store = self._get_store()
179
- predicate = _normalize_filter(filter)
188
+ exact_key, predicate = _normalize_filter(filter)
180
189
  results: list[InfiniteQuery[Any, Any]] = []
181
190
 
191
+ if exact_key is not None:
192
+ entry = store.get_infinite(exact_key)
193
+ return [entry] if entry is not None else []
194
+
182
195
  for key, entry in store.items():
183
196
  if not isinstance(entry, InfiniteQuery):
184
197
  continue
@@ -239,7 +252,7 @@ class QueryClient:
239
252
  @overload
240
253
  def set_data(
241
254
  self,
242
- key_or_filter: list[QueryKey] | Callable[[QueryKey], bool],
255
+ key_or_filter: QueryKeys | Callable[[Key], bool],
243
256
  data: Callable[[Any], Any],
244
257
  *,
245
258
  updated_at: float | dt.datetime | None = None,
@@ -247,7 +260,7 @@ class QueryClient:
247
260
 
248
261
  def set_data(
249
262
  self,
250
- key_or_filter: QueryKey | list[QueryKey] | Callable[[QueryKey], bool],
263
+ key_or_filter: QueryKey | QueryKeys | Callable[[Key], bool],
251
264
  data: Any | Callable[[Any], Any],
252
265
  *,
253
266
  updated_at: float | dt.datetime | None = None,
@@ -266,16 +279,15 @@ class QueryClient:
266
279
  Returns:
267
280
  bool if exact key, int count if filter.
268
281
  """
269
- # Single key case
270
- if isinstance(key_or_filter, tuple):
271
- query = self.get(key_or_filter)
282
+ exact_key, predicate = _normalize_filter(key_or_filter)
283
+ if exact_key is not None:
284
+ query = self.get(exact_key)
272
285
  if query is None:
273
286
  return False
274
287
  query.set_data(data, updated_at=updated_at)
275
288
  return True
276
289
 
277
- # Filter case
278
- queries = self.get_queries(key_or_filter)
290
+ queries = self.get_queries(predicate)
279
291
  for q in queries:
280
292
  q.set_data(data, updated_at=updated_at)
281
293
  return len(queries)
@@ -319,17 +331,14 @@ class QueryClient:
319
331
  @overload
320
332
  def invalidate(
321
333
  self,
322
- key_or_filter: list[QueryKey] | Callable[[QueryKey], bool] | None = None,
334
+ key_or_filter: QueryKeys | Callable[[Key], bool] | None = None,
323
335
  *,
324
336
  cancel_refetch: bool = False,
325
337
  ) -> int: ...
326
338
 
327
339
  def invalidate(
328
340
  self,
329
- key_or_filter: QueryKey
330
- | list[QueryKey]
331
- | Callable[[QueryKey], bool]
332
- | None = None,
341
+ key_or_filter: QueryKey | QueryKeys | Callable[[Key], bool] | None = None,
333
342
  *,
334
343
  cancel_refetch: bool = False,
335
344
  ) -> bool | int:
@@ -346,20 +355,19 @@ class QueryClient:
346
355
  Returns:
347
356
  bool if exact key, int count if filter/None.
348
357
  """
349
- # Single key case
350
- if isinstance(key_or_filter, tuple):
351
- query = self.get(key_or_filter)
358
+ exact_key, predicate = _normalize_filter(key_or_filter)
359
+ if exact_key is not None:
360
+ query = self.get(exact_key)
352
361
  if query is not None:
353
362
  query.invalidate(cancel_refetch=cancel_refetch)
354
363
  return True
355
- inf_query = self.get_infinite(key_or_filter)
364
+ inf_query = self.get_infinite(exact_key)
356
365
  if inf_query is not None:
357
366
  inf_query.invalidate(cancel_fetch=cancel_refetch)
358
367
  return True
359
368
  return False
360
369
 
361
- # Filter case
362
- queries = self.get_all(key_or_filter)
370
+ queries = self.get_all(predicate)
363
371
  for q in queries:
364
372
  if isinstance(q, InfiniteQuery):
365
373
  q.invalidate(cancel_fetch=cancel_refetch)
@@ -369,14 +377,14 @@ class QueryClient:
369
377
 
370
378
  def invalidate_prefix(
371
379
  self,
372
- prefix: tuple[Any, ...],
380
+ prefix: QueryKey,
373
381
  *,
374
382
  cancel_refetch: bool = False,
375
383
  ) -> int:
376
384
  """Invalidate all queries whose keys start with the given prefix.
377
385
 
378
386
  Args:
379
- prefix: Tuple prefix to match against query keys.
387
+ prefix: Key prefix to match against query keys.
380
388
  cancel_refetch: Cancel in-flight requests before refetch.
381
389
 
382
390
  Returns:
@@ -429,7 +437,7 @@ class QueryClient:
429
437
  """Refetch all queries matching the filter.
430
438
 
431
439
  Args:
432
- filter: Optional filter - exact key, list of keys, or predicate.
440
+ filter: Optional filter - exact key, QueryKeys, or predicate.
433
441
  If None, refetches all queries.
434
442
  cancel_refetch: Cancel in-flight requests before refetching.
435
443
 
@@ -450,14 +458,14 @@ class QueryClient:
450
458
 
451
459
  async def refetch_prefix(
452
460
  self,
453
- prefix: tuple[Any, ...],
461
+ prefix: QueryKey,
454
462
  *,
455
463
  cancel_refetch: bool = True,
456
464
  ) -> list[ActionResult[Any]]:
457
465
  """Refetch all queries whose keys start with the given prefix.
458
466
 
459
467
  Args:
460
- prefix: Tuple prefix to match against query keys.
468
+ prefix: Key prefix to match against query keys.
461
469
  cancel_refetch: Cancel in-flight requests before refetching.
462
470
 
463
471
  Returns:
@@ -524,7 +532,7 @@ class QueryClient:
524
532
  """Remove all queries matching the filter.
525
533
 
526
534
  Args:
527
- filter: Optional filter - exact key, list of keys, or predicate.
535
+ filter: Optional filter - exact key, QueryKeys, or predicate.
528
536
  If None, removes all queries.
529
537
 
530
538
  Returns:
@@ -535,11 +543,11 @@ class QueryClient:
535
543
  q.dispose()
536
544
  return len(queries)
537
545
 
538
- def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
546
+ def remove_prefix(self, prefix: QueryKey) -> int:
539
547
  """Remove all queries whose keys start with the given prefix.
540
548
 
541
549
  Args:
542
- prefix: Tuple prefix to match against query keys.
550
+ prefix: Key prefix to match against query keys.
543
551
 
544
552
  Returns:
545
553
  Count of removed queries.
@@ -554,7 +562,7 @@ class QueryClient:
554
562
  """Check if any query matching the filter is currently fetching.
555
563
 
556
564
  Args:
557
- filter: Optional filter - exact key, list of keys, or predicate.
565
+ filter: Optional filter - exact key, QueryKeys, or predicate.
558
566
  If None, checks all queries.
559
567
 
560
568
  Returns:
@@ -570,7 +578,7 @@ class QueryClient:
570
578
  """Check if any query matching the filter is in loading state.
571
579
 
572
580
  Args:
573
- filter: Optional filter - exact key, list of keys, or predicate.
581
+ filter: Optional filter - exact key, QueryKeys, or predicate.
574
582
  If None, checks all queries.
575
583
 
576
584
  Returns:
@@ -9,6 +9,8 @@ from typing import (
9
9
  ParamSpec,
10
10
  TypeAlias,
11
11
  TypeVar,
12
+ final,
13
+ override,
12
14
  )
13
15
 
14
16
  from pulse.state import State
@@ -18,13 +20,62 @@ TState = TypeVar("TState", bound="State")
18
20
  P = ParamSpec("P")
19
21
  R = TypeVar("R")
20
22
 
21
- QueryKey: TypeAlias = tuple[Hashable, ...]
22
- """Tuple of hashable values identifying a query in the store.
23
+
24
+ @final
25
+ class Key(tuple[Hashable, ...]):
26
+ """Normalized query key with a precomputed hash."""
27
+
28
+ _hash: int = 0
29
+
30
+ def __new__(cls, key: "QueryKey"):
31
+ if isinstance(key, Key):
32
+ return key
33
+ if isinstance(key, (list, tuple)):
34
+ parts = tuple(key)
35
+ try:
36
+ key_hash = hash(parts)
37
+ except TypeError:
38
+ raise TypeError("QueryKey values must be hashable") from None
39
+ obj = super().__new__(cls, parts)
40
+ obj._hash = key_hash
41
+ return obj
42
+ raise TypeError("QueryKey must be a list or tuple of hashable values")
43
+
44
+ @override
45
+ def __hash__(self) -> int:
46
+ return self._hash
47
+
48
+
49
+ QueryKey: TypeAlias = tuple[Hashable, ...] | list[Hashable] | Key
50
+ """List/tuple of hashable values identifying a query in the store.
23
51
 
24
52
  Used to uniquely identify queries for caching, deduplication, and invalidation.
25
- Keys are hierarchical tuples like ``("user", user_id)`` or ``("posts", "feed")``.
53
+ Keys are hierarchical lists/tuples like ``("user", user_id)`` or ``["posts", "feed"]``.
54
+ Lists are normalized to a tuple-backed Key internally.
26
55
  """
27
56
 
57
+
58
+ def normalize_key(key: QueryKey) -> Key:
59
+ """Convert a query key to a normalized key for use as a dict key."""
60
+ return Key(key)
61
+
62
+
63
+ @final
64
+ @dataclass(frozen=True, slots=True)
65
+ class QueryKeys:
66
+ """Wrapper for selecting multiple query keys."""
67
+
68
+ keys: tuple[Key, ...]
69
+
70
+ def __init__(self, *keys: QueryKey):
71
+ object.__setattr__(self, "keys", tuple(normalize_key(key) for key in keys))
72
+
73
+
74
+ def keys(*query_keys: QueryKey) -> QueryKeys:
75
+ """Create a QueryKeys wrapper for filtering by multiple keys."""
76
+ return QueryKeys(*query_keys)
77
+
78
+
28
79
  QueryStatus: TypeAlias = Literal["loading", "success", "error"]
29
80
  """Current status of a query.
30
81
 
@@ -27,11 +27,13 @@ from pulse.queries.common import (
27
27
  ActionError,
28
28
  ActionResult,
29
29
  ActionSuccess,
30
+ Key,
30
31
  OnErrorFn,
31
32
  OnSuccessFn,
32
33
  QueryKey,
33
34
  QueryStatus,
34
35
  bind_state,
36
+ normalize_key,
35
37
  )
36
38
  from pulse.queries.query import RETRY_DELAY_DEFAULT, QueryConfig
37
39
  from pulse.reactive import Computed, Effect, Signal, Untrack
@@ -115,6 +117,7 @@ class RefetchPage(Generic[T, TParam]):
115
117
  fetch_fn: Callable[[TParam], Awaitable[T]]
116
118
  param: TParam
117
119
  observer: "InfiniteQueryResult[T, TParam] | None" = None
120
+ clear: bool = False
118
121
  future: "asyncio.Future[ActionResult[T | None]]" = field(
119
122
  default_factory=asyncio.Future
120
123
  )
@@ -141,7 +144,7 @@ class InfiniteQueryConfig(QueryConfig[list[Page[T, TParam]]], Generic[T, TParam]
141
144
  class InfiniteQuery(Generic[T, TParam], Disposable):
142
145
  """Paginated query that stores data as a list of Page(data, param)."""
143
146
 
144
- key: QueryKey
147
+ key: Key
145
148
  cfg: InfiniteQueryConfig[T, TParam]
146
149
 
147
150
  @property
@@ -248,7 +251,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
248
251
  gc_time: float = 300.0,
249
252
  on_dispose: Callable[[Any], None] | None = None,
250
253
  ):
251
- self.key = key
254
+ self.key = normalize_key(key)
252
255
 
253
256
  self.cfg = InfiniteQueryConfig(
254
257
  retries=retries,
@@ -305,7 +308,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
305
308
 
306
309
  for obs in self._observers:
307
310
  if obs._on_success is not None: # pyright: ignore[reportPrivateUsage]
308
- await maybe_await(call_flexible(obs._on_success, self.pages)) # pyright: ignore[reportPrivateUsage]
311
+ with Untrack():
312
+ await maybe_await(call_flexible(obs._on_success, self.pages)) # pyright: ignore[reportPrivateUsage]
309
313
 
310
314
  async def _commit_error(self, error: Exception):
311
315
  """Commit error state and run error callbacks."""
@@ -313,7 +317,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
313
317
 
314
318
  for obs in self._observers:
315
319
  if obs._on_error is not None: # pyright: ignore[reportPrivateUsage]
316
- await maybe_await(call_flexible(obs._on_error, error)) # pyright: ignore[reportPrivateUsage]
320
+ with Untrack():
321
+ await maybe_await(call_flexible(obs._on_error, error)) # pyright: ignore[reportPrivateUsage]
317
322
 
318
323
  def _commit_sync(self):
319
324
  """Synchronous commit - updates state based on current pages."""
@@ -703,8 +708,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
703
708
 
704
709
  page = await action.fetch_fn(action.param)
705
710
 
706
- if idx is None:
707
- # Page doesn't exist - jump to this page, clearing existing pages
711
+ if action.clear or idx is None:
712
+ # clear=True or page doesn't exist - replace all pages with just this one
708
713
  self.pages.clear()
709
714
  self.pages.append(Page(page, action.param))
710
715
  else:
@@ -782,12 +787,13 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
782
787
  *,
783
788
  observer: "InfiniteQueryResult[T, TParam] | None" = None,
784
789
  cancel_fetch: bool = False,
790
+ clear: bool = False,
785
791
  ) -> ActionResult[T | None]:
786
792
  """
787
793
  Refetch a page by its param. Queued for sequential execution.
788
794
 
789
- If the page doesn't exist, clears existing pages and loads the requested
790
- page as the new starting point.
795
+ If the page doesn't exist or clear=True, clears existing pages and loads
796
+ the requested page as the new starting point.
791
797
 
792
798
  Note: Prefer calling refetch_page() on InfiniteQueryResult to ensure the
793
799
  correct fetch function is used. When called directly on InfiniteQuery, uses
@@ -795,7 +801,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
795
801
  """
796
802
  fn = fetch_fn if fetch_fn is not None else self.fn
797
803
  action: RefetchPage[T, TParam] = RefetchPage(
798
- fetch_fn=fn, param=param, observer=observer
804
+ fetch_fn=fn, param=param, observer=observer, clear=clear
799
805
  )
800
806
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
801
807
 
@@ -1019,12 +1025,22 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
1019
1025
  page_param: TParam,
1020
1026
  *,
1021
1027
  cancel_fetch: bool = False,
1028
+ clear: bool = False,
1022
1029
  ) -> ActionResult[T | None]:
1030
+ """Fetch a specific page by its param.
1031
+
1032
+ Args:
1033
+ page_param: The page parameter to fetch.
1034
+ cancel_fetch: Cancel any in-flight fetches before starting.
1035
+ clear: If True, clears all other pages and keeps only the fetched page.
1036
+ Useful for resetting pagination to a specific page.
1037
+ """
1023
1038
  return await self._query().refetch_page(
1024
1039
  page_param,
1025
1040
  fetch_fn=self._fetch_fn,
1026
1041
  observer=self,
1027
1042
  cancel_fetch=cancel_fetch,
1043
+ clear=clear,
1028
1044
  )
1029
1045
 
1030
1046
  def set_initial_data(
@@ -1149,7 +1165,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1149
1165
  Callable[[TState, list[Page[T, TParam]]], TParam | None] | None
1150
1166
  )
1151
1167
  _max_pages: int
1152
- _key: QueryKey | Callable[[TState], QueryKey] | None
1168
+ _key: Key | Callable[[TState], Key] | None
1153
1169
  # Not using OnSuccessFn and OnErrorFn since unions of callables are not well
1154
1170
  # supported in the type system. We just need to be careful to use
1155
1171
  # call_flexible to invoke these functions.
@@ -1193,7 +1209,17 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1193
1209
  self._on_success_fn = None
1194
1210
  self._on_error_fn = None
1195
1211
  self._initial_data = MISSING
1196
- self._key = key
1212
+ if key is None:
1213
+ self._key = None
1214
+ elif callable(key):
1215
+ key_fn = key
1216
+
1217
+ def normalized_key(state: TState) -> Key:
1218
+ return normalize_key(key_fn(state))
1219
+
1220
+ self._key = normalized_key
1221
+ else:
1222
+ self._key = normalize_key(key)
1197
1223
  self._initial_data_updated_at = initial_data_updated_at
1198
1224
  self._enabled = enabled
1199
1225
  self._fetch_on_mount = fetch_on_mount
@@ -1204,7 +1230,11 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1204
1230
  raise RuntimeError(
1205
1231
  f"Cannot use @{self.name}.key decorator when a key is already provided to @infinite_query(key=...)."
1206
1232
  )
1207
- self._key = fn
1233
+
1234
+ def normalized_key(state: TState) -> Key:
1235
+ return normalize_key(fn(state))
1236
+
1237
+ self._key = normalized_key
1208
1238
  return fn
1209
1239
 
1210
1240
  def on_success(self, fn: OnSuccessFn[TState, list[T]]):
@@ -1384,6 +1414,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1384
1414
  def infinite_query(
1385
1415
  fn: Callable[[TState, TParam], Awaitable[T]],
1386
1416
  *,
1417
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1387
1418
  initial_page_param: TParam,
1388
1419
  max_pages: int = 0,
1389
1420
  stale_time: float = 0.0,
@@ -1395,7 +1426,6 @@ def infinite_query(
1395
1426
  initial_data_updated_at: float | dt.datetime | None = None,
1396
1427
  enabled: bool = True,
1397
1428
  fetch_on_mount: bool = True,
1398
- key: QueryKey | None = None,
1399
1429
  ) -> InfiniteQueryProperty[T, TParam, TState]: ...
1400
1430
 
1401
1431
 
@@ -1403,6 +1433,7 @@ def infinite_query(
1403
1433
  def infinite_query(
1404
1434
  fn: None = None,
1405
1435
  *,
1436
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1406
1437
  initial_page_param: TParam,
1407
1438
  max_pages: int = 0,
1408
1439
  stale_time: float = 0.0,
@@ -1414,7 +1445,6 @@ def infinite_query(
1414
1445
  initial_data_updated_at: float | dt.datetime | None = None,
1415
1446
  enabled: bool = True,
1416
1447
  fetch_on_mount: bool = True,
1417
- key: QueryKey | None = None,
1418
1448
  ) -> Callable[
1419
1449
  [Callable[[TState, Any], Awaitable[T]]],
1420
1450
  InfiniteQueryProperty[T, TParam, TState],
@@ -1424,6 +1454,7 @@ def infinite_query(
1424
1454
  def infinite_query(
1425
1455
  fn: Callable[[TState, TParam], Awaitable[T]] | None = None,
1426
1456
  *,
1457
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1427
1458
  initial_page_param: TParam,
1428
1459
  max_pages: int = 0,
1429
1460
  stale_time: float = 0.0,
@@ -1435,7 +1466,6 @@ def infinite_query(
1435
1466
  initial_data_updated_at: float | dt.datetime | None = None,
1436
1467
  enabled: bool = True,
1437
1468
  fetch_on_mount: bool = True,
1438
- key: QueryKey | None = None,
1439
1469
  ) -> (
1440
1470
  InfiniteQueryProperty[T, TParam, TState]
1441
1471
  | Callable[
@@ -1449,7 +1479,8 @@ def infinite_query(
1449
1479
  pagination. Data is stored as a list of pages, each with its data and the
1450
1480
  parameter used to fetch it.
1451
1481
 
1452
- Requires ``@query_prop.key`` and ``@query_prop.get_next_page_param`` decorators.
1482
+ Requires a key (``key=`` or ``@query_prop.key``) and
1483
+ ``@query_prop.get_next_page_param`` decorator.
1453
1484
 
1454
1485
  Args:
1455
1486
  fn: The async method to decorate (when used without parentheses).