pulse-framework 0.1.62__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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,609 @@
1
+ import datetime as dt
2
+ from collections.abc import Callable
3
+ from typing import Any, TypeVar, overload
4
+
5
+ from pulse.context import PulseContext
6
+ from pulse.queries.common import ActionResult, QueryKey
7
+ from pulse.queries.infinite_query import InfiniteQuery, Page
8
+ from pulse.queries.query import KeyedQuery
9
+ from pulse.queries.store import QueryStore
10
+
11
+ T = TypeVar("T")
12
+
13
+ # Query filter types
14
+ QueryFilter = (
15
+ QueryKey # exact key match
16
+ | list[QueryKey] # explicit list of keys
17
+ | Callable[[QueryKey], bool] # predicate function
18
+ )
19
+
20
+
21
+ def _normalize_filter(
22
+ filter: QueryFilter | None,
23
+ ) -> Callable[[QueryKey], bool] | None:
24
+ """Convert any QueryFilter to a predicate function."""
25
+ if filter is None:
26
+ return None
27
+ if isinstance(filter, tuple):
28
+ # Exact key match
29
+ exact_key = filter
30
+ return lambda k: k == exact_key
31
+ if isinstance(filter, list):
32
+ # List of keys
33
+ key_set = set(filter)
34
+ return lambda k: k in key_set
35
+ # Already a callable predicate
36
+ return filter
37
+
38
+
39
+ def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
40
+ """Create a predicate that matches keys starting with the given prefix."""
41
+ prefix_len = len(prefix)
42
+ return lambda k: len(k) >= prefix_len and k[:prefix_len] == prefix
43
+
44
+
45
+ class QueryClient:
46
+ """Client for managing queries and infinite queries in a session.
47
+
48
+ Provides methods to get, set, invalidate, and refetch queries by key
49
+ or using filter predicates. Automatically resolves to the current
50
+ RenderSession's query store.
51
+
52
+ Access via ``ps.queries`` singleton:
53
+
54
+ Example:
55
+
56
+ ```python
57
+ # Get query data
58
+ user = ps.queries.get_data(("user", user_id))
59
+
60
+ # Invalidate queries by prefix
61
+ ps.queries.invalidate_prefix(("users",))
62
+
63
+ # Set data optimistically
64
+ ps.queries.set_data(("user", user_id), updated_user)
65
+
66
+ # Check if any query is fetching
67
+ if ps.queries.is_fetching(("user", user_id)):
68
+ show_loading()
69
+ ```
70
+ """
71
+
72
+ def _get_store(self) -> QueryStore:
73
+ """Get the query store from the current PulseContext.
74
+
75
+ Returns:
76
+ The QueryStore from the active render session.
77
+
78
+ Raises:
79
+ RuntimeError: If no render session is available.
80
+ """
81
+ render = PulseContext.get().render
82
+ if render is None:
83
+ raise RuntimeError("No render session available")
84
+ return render.query_store
85
+
86
+ # ─────────────────────────────────────────────────────────────────────────
87
+ # Query accessors
88
+ # ─────────────────────────────────────────────────────────────────────────
89
+
90
+ def get(self, key: QueryKey) -> KeyedQuery[Any] | None:
91
+ """Get an existing regular query by key.
92
+
93
+ Args:
94
+ key: The query key tuple to look up.
95
+
96
+ Returns:
97
+ The KeyedQuery instance, or None if not found.
98
+ """
99
+ return self._get_store().get(key)
100
+
101
+ def get_infinite(self, key: QueryKey) -> InfiniteQuery[Any, Any] | None:
102
+ """Get an existing infinite query by key.
103
+
104
+ Args:
105
+ key: The query key tuple to look up.
106
+
107
+ Returns:
108
+ The InfiniteQuery instance, or None if not found.
109
+ """
110
+ return self._get_store().get_infinite(key)
111
+
112
+ def get_all(
113
+ self,
114
+ filter: QueryFilter | None = None,
115
+ *,
116
+ include_infinite: bool = True,
117
+ ) -> list[KeyedQuery[Any] | InfiniteQuery[Any, Any]]:
118
+ """
119
+ Get all queries matching the filter.
120
+
121
+ Args:
122
+ filter: Optional filter - can be an exact key, list of keys, or predicate.
123
+ If None, returns all queries.
124
+ include_infinite: Whether to include infinite queries (default True).
125
+
126
+ Returns:
127
+ List of matching Query or InfiniteQuery instances.
128
+ """
129
+ store = self._get_store()
130
+ predicate = _normalize_filter(filter)
131
+ results: list[KeyedQuery[Any] | InfiniteQuery[Any, Any]] = []
132
+
133
+ for key, entry in store.items():
134
+ if predicate is not None and not predicate(key):
135
+ continue
136
+ if not include_infinite and isinstance(entry, InfiniteQuery):
137
+ continue
138
+ results.append(entry)
139
+
140
+ return results
141
+
142
+ def get_queries(self, filter: QueryFilter | None = None) -> list[KeyedQuery[Any]]:
143
+ """Get all regular queries matching the filter.
144
+
145
+ Args:
146
+ filter: Optional filter - exact key, list of keys, or predicate.
147
+ If None, returns all regular queries.
148
+
149
+ Returns:
150
+ List of matching KeyedQuery instances (excludes infinite queries).
151
+ """
152
+ store = self._get_store()
153
+ predicate = _normalize_filter(filter)
154
+ results: list[KeyedQuery[Any]] = []
155
+
156
+ for key, entry in store.items():
157
+ if isinstance(entry, InfiniteQuery):
158
+ continue
159
+ if predicate is not None and not predicate(key):
160
+ continue
161
+ results.append(entry)
162
+
163
+ return results
164
+
165
+ def get_infinite_queries(
166
+ self, filter: QueryFilter | None = None
167
+ ) -> list[InfiniteQuery[Any, Any]]:
168
+ """Get all infinite queries matching the filter.
169
+
170
+ Args:
171
+ filter: Optional filter - exact key, list of keys, or predicate.
172
+ If None, returns all infinite queries.
173
+
174
+ Returns:
175
+ List of matching InfiniteQuery instances.
176
+ """
177
+ store = self._get_store()
178
+ predicate = _normalize_filter(filter)
179
+ results: list[InfiniteQuery[Any, Any]] = []
180
+
181
+ for key, entry in store.items():
182
+ if not isinstance(entry, InfiniteQuery):
183
+ continue
184
+ if predicate is not None and not predicate(key):
185
+ continue
186
+ results.append(entry)
187
+
188
+ return results
189
+
190
+ # ─────────────────────────────────────────────────────────────────────────
191
+ # Data accessors
192
+ # ─────────────────────────────────────────────────────────────────────────
193
+
194
+ def get_data(self, key: QueryKey) -> Any | None:
195
+ """Get the data for a query by key.
196
+
197
+ Args:
198
+ key: The query key tuple to look up.
199
+
200
+ Returns:
201
+ The query data, or None if query not found or has no data.
202
+ """
203
+ query = self.get(key)
204
+ if query is None:
205
+ return None
206
+ return query.data.read()
207
+
208
+ def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
209
+ """Get the pages for an infinite query by key.
210
+
211
+ Args:
212
+ key: The query key tuple to look up.
213
+
214
+ Returns:
215
+ List of Page objects, or None if query not found.
216
+ """
217
+ query = self.get_infinite(key)
218
+ if query is None:
219
+ return None
220
+ return list(query.pages)
221
+
222
+ # ─────────────────────────────────────────────────────────────────────────
223
+ # Data setters
224
+ # ─────────────────────────────────────────────────────────────────────────
225
+
226
+ @overload
227
+ def set_data(
228
+ self,
229
+ key_or_filter: QueryKey,
230
+ data: T | Callable[[T | None], T],
231
+ *,
232
+ updated_at: float | dt.datetime | None = None,
233
+ ) -> bool: ...
234
+
235
+ @overload
236
+ def set_data(
237
+ self,
238
+ key_or_filter: list[QueryKey] | Callable[[QueryKey], bool],
239
+ data: Callable[[Any], Any],
240
+ *,
241
+ updated_at: float | dt.datetime | None = None,
242
+ ) -> int: ...
243
+
244
+ def set_data(
245
+ self,
246
+ key_or_filter: QueryKey | list[QueryKey] | Callable[[QueryKey], bool],
247
+ data: Any | Callable[[Any], Any],
248
+ *,
249
+ updated_at: float | dt.datetime | None = None,
250
+ ) -> bool | int:
251
+ """
252
+ Set data for queries matching the key or filter.
253
+
254
+ When using a single key, returns True if query exists and was updated.
255
+ When using a filter, returns count of updated queries.
256
+
257
+ Args:
258
+ key_or_filter: Exact key or filter predicate.
259
+ data: New data value or updater function.
260
+ updated_at: Optional timestamp to set.
261
+
262
+ Returns:
263
+ bool if exact key, int count if filter.
264
+ """
265
+ # Single key case
266
+ if isinstance(key_or_filter, tuple):
267
+ query = self.get(key_or_filter)
268
+ if query is None:
269
+ return False
270
+ query.set_data(data, updated_at=updated_at)
271
+ return True
272
+
273
+ # Filter case
274
+ queries = self.get_queries(key_or_filter)
275
+ for q in queries:
276
+ q.set_data(data, updated_at=updated_at)
277
+ return len(queries)
278
+
279
+ def set_infinite_data(
280
+ self,
281
+ key: QueryKey,
282
+ pages: list[Page[Any, Any]]
283
+ | Callable[[list[Page[Any, Any]]], list[Page[Any, Any]]],
284
+ *,
285
+ updated_at: float | dt.datetime | None = None,
286
+ ) -> bool:
287
+ """Set pages for an infinite query by key.
288
+
289
+ Args:
290
+ key: The query key tuple.
291
+ pages: New pages list or updater function.
292
+ updated_at: Optional timestamp to set.
293
+
294
+ Returns:
295
+ True if query was found and updated, False otherwise.
296
+ """
297
+ query = self.get_infinite(key)
298
+ if query is None:
299
+ return False
300
+ query.set_data(pages, updated_at=updated_at)
301
+ return True
302
+
303
+ # ─────────────────────────────────────────────────────────────────────────
304
+ # Invalidation
305
+ # ─────────────────────────────────────────────────────────────────────────
306
+
307
+ @overload
308
+ def invalidate(
309
+ self,
310
+ key_or_filter: QueryKey,
311
+ *,
312
+ cancel_refetch: bool = False,
313
+ ) -> bool: ...
314
+
315
+ @overload
316
+ def invalidate(
317
+ self,
318
+ key_or_filter: list[QueryKey] | Callable[[QueryKey], bool] | None = None,
319
+ *,
320
+ cancel_refetch: bool = False,
321
+ ) -> int: ...
322
+
323
+ def invalidate(
324
+ self,
325
+ key_or_filter: QueryKey
326
+ | list[QueryKey]
327
+ | Callable[[QueryKey], bool]
328
+ | None = None,
329
+ *,
330
+ cancel_refetch: bool = False,
331
+ ) -> bool | int:
332
+ """
333
+ Invalidate queries matching the key or filter.
334
+
335
+ For regular queries: marks as stale and refetches if observed.
336
+ For infinite queries: triggers refetch of all pages if observed.
337
+
338
+ Args:
339
+ key_or_filter: Exact key, filter predicate, or None for all.
340
+ cancel_refetch: Cancel in-flight requests before refetch.
341
+
342
+ Returns:
343
+ bool if exact key, int count if filter/None.
344
+ """
345
+ # Single key case
346
+ if isinstance(key_or_filter, tuple):
347
+ query = self.get(key_or_filter)
348
+ if query is not None:
349
+ query.invalidate(cancel_refetch=cancel_refetch)
350
+ return True
351
+ inf_query = self.get_infinite(key_or_filter)
352
+ if inf_query is not None:
353
+ inf_query.invalidate(cancel_fetch=cancel_refetch)
354
+ return True
355
+ return False
356
+
357
+ # Filter case
358
+ queries = self.get_all(key_or_filter)
359
+ for q in queries:
360
+ if isinstance(q, InfiniteQuery):
361
+ q.invalidate(cancel_fetch=cancel_refetch)
362
+ else:
363
+ q.invalidate(cancel_refetch=cancel_refetch)
364
+ return len(queries)
365
+
366
+ def invalidate_prefix(
367
+ self,
368
+ prefix: tuple[Any, ...],
369
+ *,
370
+ cancel_refetch: bool = False,
371
+ ) -> int:
372
+ """Invalidate all queries whose keys start with the given prefix.
373
+
374
+ Args:
375
+ prefix: Tuple prefix to match against query keys.
376
+ cancel_refetch: Cancel in-flight requests before refetch.
377
+
378
+ Returns:
379
+ Count of invalidated queries.
380
+
381
+ Example:
382
+
383
+ ```python
384
+ # Invalidates ("users",), ("users", 1), ("users", 2, "posts"), etc.
385
+ ps.queries.invalidate_prefix(("users",))
386
+ ```
387
+ """
388
+ return self.invalidate(_prefix_filter(prefix), cancel_refetch=cancel_refetch)
389
+
390
+ # ─────────────────────────────────────────────────────────────────────────
391
+ # Refetch
392
+ # ─────────────────────────────────────────────────────────────────────────
393
+
394
+ async def refetch(
395
+ self,
396
+ key: QueryKey,
397
+ *,
398
+ cancel_refetch: bool = True,
399
+ ) -> ActionResult[Any] | None:
400
+ """Refetch a query by key and return the result.
401
+
402
+ Args:
403
+ key: The query key tuple to refetch.
404
+ cancel_refetch: Cancel in-flight request before refetching (default True).
405
+
406
+ Returns:
407
+ ActionResult with data or error, or None if query doesn't exist.
408
+ """
409
+ query = self.get(key)
410
+ if query is not None:
411
+ return await query.refetch(cancel_refetch=cancel_refetch)
412
+
413
+ inf_query = self.get_infinite(key)
414
+ if inf_query is not None:
415
+ return await inf_query.refetch(cancel_fetch=cancel_refetch)
416
+
417
+ return None
418
+
419
+ async def refetch_all(
420
+ self,
421
+ filter: QueryFilter | None = None,
422
+ *,
423
+ cancel_refetch: bool = True,
424
+ ) -> list[ActionResult[Any]]:
425
+ """Refetch all queries matching the filter.
426
+
427
+ Args:
428
+ filter: Optional filter - exact key, list of keys, or predicate.
429
+ If None, refetches all queries.
430
+ cancel_refetch: Cancel in-flight requests before refetching.
431
+
432
+ Returns:
433
+ List of ActionResult for each refetched query.
434
+ """
435
+ queries = self.get_all(filter)
436
+ results: list[ActionResult[Any]] = []
437
+
438
+ for q in queries:
439
+ if isinstance(q, InfiniteQuery):
440
+ result = await q.refetch(cancel_fetch=cancel_refetch)
441
+ else:
442
+ result = await q.refetch(cancel_refetch=cancel_refetch)
443
+ results.append(result)
444
+
445
+ return results
446
+
447
+ async def refetch_prefix(
448
+ self,
449
+ prefix: tuple[Any, ...],
450
+ *,
451
+ cancel_refetch: bool = True,
452
+ ) -> list[ActionResult[Any]]:
453
+ """Refetch all queries whose keys start with the given prefix.
454
+
455
+ Args:
456
+ prefix: Tuple prefix to match against query keys.
457
+ cancel_refetch: Cancel in-flight requests before refetching.
458
+
459
+ Returns:
460
+ List of ActionResult for each refetched query.
461
+ """
462
+ return await self.refetch_all(
463
+ _prefix_filter(prefix), cancel_refetch=cancel_refetch
464
+ )
465
+
466
+ # ─────────────────────────────────────────────────────────────────────────
467
+ # Error handling
468
+ # ─────────────────────────────────────────────────────────────────────────
469
+
470
+ def set_error(
471
+ self,
472
+ key: QueryKey,
473
+ error: Exception,
474
+ *,
475
+ updated_at: float | dt.datetime | None = None,
476
+ ) -> bool:
477
+ """Set error state on a query by key.
478
+
479
+ Args:
480
+ key: The query key tuple.
481
+ error: The exception to set.
482
+ updated_at: Optional timestamp to set.
483
+
484
+ Returns:
485
+ True if query was found and error was set, False otherwise.
486
+ """
487
+ query = self.get(key)
488
+ if query is not None:
489
+ query.set_error(error, updated_at=updated_at)
490
+ return True
491
+
492
+ inf_query = self.get_infinite(key)
493
+ if inf_query is not None:
494
+ inf_query.set_error(error, updated_at=updated_at)
495
+ return True
496
+
497
+ return False
498
+
499
+ # ─────────────────────────────────────────────────────────────────────────
500
+ # Reset / Remove
501
+ # ─────────────────────────────────────────────────────────────────────────
502
+
503
+ def remove(self, key: QueryKey) -> bool:
504
+ """Remove a query from the store, disposing it.
505
+
506
+ Args:
507
+ key: The query key tuple to remove.
508
+
509
+ Returns:
510
+ True if query existed and was removed, False otherwise.
511
+ """
512
+ store = self._get_store()
513
+ entry = store.get_any(key)
514
+ if entry is None:
515
+ return False
516
+ entry.dispose()
517
+ return True
518
+
519
+ def remove_all(self, filter: QueryFilter | None = None) -> int:
520
+ """Remove all queries matching the filter.
521
+
522
+ Args:
523
+ filter: Optional filter - exact key, list of keys, or predicate.
524
+ If None, removes all queries.
525
+
526
+ Returns:
527
+ Count of removed queries.
528
+ """
529
+ queries = self.get_all(filter)
530
+ for q in queries:
531
+ q.dispose()
532
+ return len(queries)
533
+
534
+ def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
535
+ """Remove all queries whose keys start with the given prefix.
536
+
537
+ Args:
538
+ prefix: Tuple prefix to match against query keys.
539
+
540
+ Returns:
541
+ Count of removed queries.
542
+ """
543
+ return self.remove_all(_prefix_filter(prefix))
544
+
545
+ # ─────────────────────────────────────────────────────────────────────────
546
+ # State queries
547
+ # ─────────────────────────────────────────────────────────────────────────
548
+
549
+ def is_fetching(self, filter: QueryFilter | None = None) -> bool:
550
+ """Check if any query matching the filter is currently fetching.
551
+
552
+ Args:
553
+ filter: Optional filter - exact key, list of keys, or predicate.
554
+ If None, checks all queries.
555
+
556
+ Returns:
557
+ True if any matching query is fetching.
558
+ """
559
+ queries = self.get_all(filter)
560
+ for q in queries:
561
+ if q.is_fetching():
562
+ return True
563
+ return False
564
+
565
+ def is_loading(self, filter: QueryFilter | None = None) -> bool:
566
+ """Check if any query matching the filter is in loading state.
567
+
568
+ Args:
569
+ filter: Optional filter - exact key, list of keys, or predicate.
570
+ If None, checks all queries.
571
+
572
+ Returns:
573
+ True if any matching query has status "loading".
574
+ """
575
+ queries = self.get_all(filter)
576
+ for q in queries:
577
+ if isinstance(q, InfiniteQuery):
578
+ if q.status() == "loading":
579
+ return True
580
+ elif q.status() == "loading":
581
+ return True
582
+ return False
583
+
584
+ # ─────────────────────────────────────────────────────────────────────────
585
+ # Wait helpers
586
+ # ─────────────────────────────────────────────────────────────────────────
587
+
588
+ async def wait(self, key: QueryKey) -> ActionResult[Any] | None:
589
+ """Wait for a query to complete and return the result.
590
+
591
+ Args:
592
+ key: The query key tuple to wait for.
593
+
594
+ Returns:
595
+ ActionResult with data or error, or None if query doesn't exist.
596
+ """
597
+ query = self.get(key)
598
+ if query is not None:
599
+ return await query.wait()
600
+
601
+ inf_query = self.get_infinite(key)
602
+ if inf_query is not None:
603
+ return await inf_query.wait()
604
+
605
+ return None
606
+
607
+
608
+ # Singleton instance accessible via ps.queries
609
+ queries = QueryClient()
@@ -0,0 +1,101 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from typing import (
4
+ Any,
5
+ Concatenate,
6
+ Generic,
7
+ Hashable,
8
+ Literal,
9
+ ParamSpec,
10
+ TypeAlias,
11
+ TypeVar,
12
+ )
13
+
14
+ from pulse.state import State
15
+
16
+ T = TypeVar("T")
17
+ TState = TypeVar("TState", bound="State")
18
+ P = ParamSpec("P")
19
+ R = TypeVar("R")
20
+
21
+ QueryKey: TypeAlias = tuple[Hashable, ...]
22
+ """Tuple of hashable values identifying a query in the store.
23
+
24
+ Used to uniquely identify queries for caching, deduplication, and invalidation.
25
+ Keys are hierarchical tuples like ``("user", user_id)`` or ``("posts", "feed")``.
26
+ """
27
+
28
+ QueryStatus: TypeAlias = Literal["loading", "success", "error"]
29
+ """Current status of a query.
30
+
31
+ Values:
32
+ - ``"loading"``: Query is fetching data (initial load or refetch).
33
+ - ``"success"``: Query has successfully fetched data.
34
+ - ``"error"``: Query encountered an error during fetch.
35
+ """
36
+
37
+
38
+ @dataclass(slots=True, frozen=True)
39
+ class ActionSuccess(Generic[T]):
40
+ """Successful query action result.
41
+
42
+ Returned by query operations like ``refetch()`` and ``wait()`` when the
43
+ operation completes successfully.
44
+
45
+ Attributes:
46
+ data: The fetched data of type T.
47
+ status: Always ``"success"`` for discriminated union matching.
48
+
49
+ Example:
50
+
51
+ ```python
52
+ result = await state.user.refetch()
53
+ if result.status == "success":
54
+ print(result.data)
55
+ ```
56
+ """
57
+
58
+ data: T
59
+ status: Literal["success"] = "success"
60
+
61
+
62
+ @dataclass(slots=True, frozen=True)
63
+ class ActionError:
64
+ """Failed query action result.
65
+
66
+ Returned by query operations like ``refetch()`` and ``wait()`` when the
67
+ operation fails after exhausting retries.
68
+
69
+ Attributes:
70
+ error: The exception that caused the failure.
71
+ status: Always ``"error"`` for discriminated union matching.
72
+
73
+ Example:
74
+
75
+ ```python
76
+ result = await state.user.refetch()
77
+ if result.status == "error":
78
+ print(f"Failed: {result.error}")
79
+ ```
80
+ """
81
+
82
+ error: Exception
83
+ status: Literal["error"] = "error"
84
+
85
+
86
+ ActionResult: TypeAlias = ActionSuccess[T] | ActionError
87
+ """Union type for query action results.
88
+
89
+ Either ``ActionSuccess[T]`` with data or ``ActionError`` with an exception.
90
+ Use the ``status`` field to discriminate between success and error cases.
91
+ """
92
+
93
+ OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
94
+ OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
95
+
96
+
97
+ def bind_state(
98
+ state: TState, fn: Callable[Concatenate[TState, P], R]
99
+ ) -> Callable[P, R]:
100
+ "Type-safe helper to bind a method to a state"
101
+ return fn.__get__(state, state.__class__)