pulse-framework 0.1.62__tar.gz → 0.1.64__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 (126) hide show
  1. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/__init__.py +1 -0
  4. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/dependencies.py +7 -16
  5. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/component.py +1 -1
  6. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/infinite_query.py +110 -37
  7. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/protocol.py +9 -0
  8. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/query.py +106 -37
  9. pulse_framework-0.1.64/src/pulse/requirements.py +47 -0
  10. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/imports.py +22 -1
  11. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/nodes.py +55 -12
  12. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/README.md +0 -0
  13. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/_examples.py +0 -0
  14. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/app.py +0 -0
  15. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/channel.py +0 -0
  16. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/__init__.py +0 -0
  17. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/cmd.py +0 -0
  18. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/folder_lock.py +0 -0
  19. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/helpers.py +0 -0
  20. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/logging.py +0 -0
  21. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/models.py +0 -0
  22. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/packages.py +0 -0
  23. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/processes.py +0 -0
  24. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/secrets.py +0 -0
  25. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cli/uvicorn_log_config.py +0 -0
  26. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/code_analysis.py +0 -0
  27. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/__init__.py +0 -0
  28. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/codegen.py +0 -0
  29. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/templates/__init__.py +0 -0
  30. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/templates/layout.py +0 -0
  31. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/templates/route.py +0 -0
  32. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/templates/routes_ts.py +0 -0
  33. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/codegen/utils.py +0 -0
  34. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/components/__init__.py +0 -0
  35. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/components/for_.py +0 -0
  36. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/components/if_.py +0 -0
  37. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/components/react_router.py +0 -0
  38. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/context.py +0 -0
  39. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/cookies.py +0 -0
  40. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/decorators.py +0 -0
  41. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/__init__.py +0 -0
  42. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/elements.py +0 -0
  43. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/events.py +0 -0
  44. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/props.py +0 -0
  45. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/svg.py +0 -0
  46. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/tags.py +0 -0
  47. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/dom/tags.pyi +0 -0
  48. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/env.py +0 -0
  49. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/form.py +0 -0
  50. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/helpers.py +0 -0
  51. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/__init__.py +0 -0
  52. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/core.py +0 -0
  53. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/effects.py +0 -0
  54. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/init.py +0 -0
  55. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/runtime.py +0 -0
  56. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/setup.py +0 -0
  57. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/stable.py +0 -0
  58. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/hooks/state.py +0 -0
  59. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/__init__.py +0 -0
  60. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/__init__.pyi +0 -0
  61. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/_types.py +0 -0
  62. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/array.py +0 -0
  63. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/console.py +0 -0
  64. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/date.py +0 -0
  65. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/document.py +0 -0
  66. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/error.py +0 -0
  67. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/json.py +0 -0
  68. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/map.py +0 -0
  69. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/math.py +0 -0
  70. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/navigator.py +0 -0
  71. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/number.py +0 -0
  72. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/obj.py +0 -0
  73. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/object.py +0 -0
  74. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/promise.py +0 -0
  75. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/pulse.py +0 -0
  76. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/react.py +0 -0
  77. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/regexp.py +0 -0
  78. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/set.py +0 -0
  79. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/string.py +0 -0
  80. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/weakmap.py +0 -0
  81. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/weakset.py +0 -0
  82. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/js/window.py +0 -0
  83. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/messages.py +0 -0
  84. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/middleware.py +0 -0
  85. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/plugin.py +0 -0
  86. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/proxy.py +0 -0
  87. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/py.typed +0 -0
  88. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/__init__.py +0 -0
  89. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/client.py +0 -0
  90. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/common.py +0 -0
  91. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/effect.py +0 -0
  92. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/mutation.py +0 -0
  93. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/queries/store.py +0 -0
  94. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/react_component.py +0 -0
  95. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/reactive.py +0 -0
  96. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/reactive_extensions.py +0 -0
  97. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/render_session.py +0 -0
  98. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/renderer.py +0 -0
  99. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/request.py +0 -0
  100. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/routing.py +0 -0
  101. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/serializer.py +0 -0
  102. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/state.py +0 -0
  103. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/test_helpers.py +0 -0
  104. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/__init__.py +0 -0
  105. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/assets.py +0 -0
  106. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/builtins.py +0 -0
  107. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/dynamic_import.py +0 -0
  108. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/emit_context.py +0 -0
  109. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/errors.py +0 -0
  110. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/function.py +0 -0
  111. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/id.py +0 -0
  112. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/js_module.py +0 -0
  113. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/__init__.py +0 -0
  114. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/asyncio.py +0 -0
  115. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/json.py +0 -0
  116. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/math.py +0 -0
  117. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  118. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  119. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/modules/typing.py +0 -0
  120. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/py_module.py +0 -0
  121. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/transpiler.py +0 -0
  122. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/transpiler/vdom.py +0 -0
  123. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/types/__init__.py +0 -0
  124. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/types/event_handler.py +0 -0
  125. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/src/pulse/user_session.py +0 -0
  126. {pulse_framework-0.1.62 → pulse_framework-0.1.64}/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.62
3
+ Version: 0.1.64
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.62"
3
+ version = "0.1.64"
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"
@@ -1410,6 +1410,7 @@ from pulse.render_session import run_js as run_js
1410
1410
 
1411
1411
  # Request
1412
1412
  from pulse.request import PulseRequest as PulseRequest
1413
+ from pulse.requirements import require as require
1413
1414
  from pulse.routing import Layout as Layout
1414
1415
  from pulse.routing import Route as Route
1415
1416
  from pulse.routing import RouteInfo as RouteInfo
@@ -11,11 +11,10 @@ from pulse.cli.packages import (
11
11
  is_workspace_spec,
12
12
  load_package_json,
13
13
  parse_dependency_spec,
14
- parse_install_spec,
15
14
  resolve_versions,
16
15
  spec_satisfies,
17
16
  )
18
- from pulse.transpiler.imports import get_registered_imports
17
+ from pulse.requirements import get_requirements
19
18
 
20
19
 
21
20
  def convert_pep440_to_semver(python_version: str) -> str:
@@ -98,20 +97,12 @@ def get_required_dependencies(
98
97
  "pulse-ui-client": [pulse_version],
99
98
  }
100
99
 
101
- # New transpiler v2 imports
102
- for imp in get_registered_imports():
103
- if imp.src:
104
- try:
105
- spec = parse_install_spec(imp.src)
106
- except ValueError as exc:
107
- # We might want to be more lenient here or at least log it,
108
- # but following existing pattern of raising DependencyError
109
- raise DependencyError(str(exc)) from None
110
- if spec:
111
- name_only, ver = parse_dependency_spec(spec)
112
- constraints.setdefault(name_only, []).append(ver)
113
- if imp.version:
114
- constraints.setdefault(name_only, []).append(imp.version)
100
+ for src, version in get_requirements():
101
+ name_only, ver_in_src = parse_dependency_spec(src)
102
+ if ver_in_src:
103
+ constraints.setdefault(name_only, []).append(ver_in_src)
104
+ if version:
105
+ constraints.setdefault(name_only, []).append(version)
115
106
 
116
107
  try:
117
108
  resolved = resolve_versions(constraints)
@@ -112,7 +112,7 @@ class Component(Generic[P]):
112
112
  flattened = flatten_children(
113
113
  args, # pyright: ignore[reportArgumentType]
114
114
  parent_name=f"<{self.name}>",
115
- warn_stacklevel=4,
115
+ warn_stacklevel=None,
116
116
  )
117
117
  args = tuple(flattened) # pyright: ignore[reportAssignmentType]
118
118
 
@@ -152,6 +152,61 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
152
152
  )
153
153
  return self._observers[0]._fetch_fn # pyright: ignore[reportPrivateUsage]
154
154
 
155
+ @property
156
+ def has_interval(self) -> bool:
157
+ return self._interval is not None
158
+
159
+ def _select_interval_observer(
160
+ self,
161
+ ) -> tuple[float | None, "InfiniteQueryResult[T, TParam] | None"]:
162
+ min_interval: float | None = None
163
+ selected: "InfiniteQueryResult[T, TParam] | None" = None
164
+
165
+ for obs in reversed(self._observers):
166
+ interval = obs._refetch_interval # pyright: ignore[reportPrivateUsage]
167
+ if interval is None:
168
+ continue
169
+ if not obs._enabled.value: # pyright: ignore[reportPrivateUsage]
170
+ continue
171
+ if min_interval is None or interval < min_interval:
172
+ min_interval = interval
173
+ selected = obs
174
+
175
+ return min_interval, selected
176
+
177
+ def _create_interval_effect(self, interval: float) -> Effect:
178
+ def interval_fn():
179
+ observer = self._interval_observer
180
+ if observer is None:
181
+ return
182
+ self.invalidate(fetch_fn=observer._fetch_fn, observer=observer) # pyright: ignore[reportPrivateUsage]
183
+
184
+ return Effect(
185
+ interval_fn,
186
+ name=f"inf_query_interval({self.key})",
187
+ interval=interval,
188
+ immediate=True,
189
+ )
190
+
191
+ def _update_interval(self) -> None:
192
+ new_interval, new_observer = self._select_interval_observer()
193
+ interval_changed = new_interval != self._interval
194
+
195
+ self._interval = new_interval
196
+ self._interval_observer = new_observer
197
+
198
+ if not interval_changed:
199
+ if self._interval_effect is None and new_interval is not None:
200
+ self._interval_effect = self._create_interval_effect(new_interval)
201
+ return
202
+
203
+ if self._interval_effect is not None:
204
+ self._interval_effect.dispose()
205
+ self._interval_effect = None
206
+
207
+ if new_interval is not None:
208
+ self._interval_effect = self._create_interval_effect(new_interval)
209
+
155
210
  # Reactive state
156
211
  pages: ReactiveList[Page[T, TParam]]
157
212
  error: Signal[Exception | None]
@@ -171,6 +226,9 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
171
226
 
172
227
  _observers: "list[InfiniteQueryResult[T, TParam]]"
173
228
  _gc_handle: asyncio.TimerHandle | None
229
+ _interval_effect: Effect | None
230
+ _interval: float | None
231
+ _interval_observer: "InfiniteQueryResult[T, TParam] | None"
174
232
 
175
233
  def __init__(
176
234
  self,
@@ -232,6 +290,9 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
232
290
  self._queue_task = None
233
291
  self._observers = []
234
292
  self._gc_handle = None
293
+ self._interval_effect = None
294
+ self._interval = None
295
+ self._interval_observer = None
235
296
 
236
297
  # ─────────────────────────────────────────────────────────────────────────
237
298
  # Commit functions - update state after pages have been modified
@@ -326,13 +387,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
326
387
  fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
327
388
  observer: "InfiniteQueryResult[T, TParam] | None" = None,
328
389
  ) -> ActionResult[list[Page[T, TParam]]]:
329
- """Wait for initial data or until queue is empty."""
330
- # If no data and loading, enqueue initial fetch (unless already processing)
331
- if len(self.pages) == 0 and self.status() == "loading":
332
- if self._queue_task is None or self._queue_task.done():
333
- # Use provided fetch_fn or fall back to first observer's fetch_fn
334
- fn = fetch_fn if fetch_fn is not None else self.fn
335
- self._enqueue(Refetch(fetch_fn=fn, observer=observer))
390
+ """Wait for any in-flight queue processing to complete."""
336
391
  # Wait for any in-progress queue processing
337
392
  if self._queue_task and not self._queue_task.done():
338
393
  await self._queue_task
@@ -341,17 +396,31 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
341
396
  return ActionError(cast(Exception, self.error()))
342
397
  return ActionSuccess(list(self.pages))
343
398
 
399
+ async def ensure(
400
+ self,
401
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
402
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
403
+ ) -> ActionResult[list[Page[T, TParam]]]:
404
+ """Ensure an initial fetch has started, then wait for completion."""
405
+ if len(self.pages) == 0 and self.status() == "loading":
406
+ if self._queue_task is None or self._queue_task.done():
407
+ fn = fetch_fn if fetch_fn is not None else self.fn
408
+ self._enqueue(Refetch(fetch_fn=fn, observer=observer))
409
+ return await self.wait()
410
+
344
411
  def observe(self, observer: Any):
345
412
  self._observers.append(observer)
346
413
  self.cancel_gc()
347
414
  gc_time = getattr(observer, "_gc_time", 0)
348
415
  if gc_time and gc_time > 0:
349
416
  self.cfg.gc_time = max(self.cfg.gc_time, gc_time)
417
+ self._update_interval()
350
418
 
351
419
  def unobserve(self, observer: "InfiniteQueryResult[T, TParam]"):
352
420
  """Unregister an observer. Cancels pending actions. Schedules GC if no observers remain."""
353
421
  if observer in self._observers:
354
422
  self._observers.remove(observer)
423
+ self._update_interval()
355
424
 
356
425
  # Cancel pending actions from this observer
357
426
  self._cancel_observer_actions(observer)
@@ -630,11 +699,17 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
630
699
  (i for i, p in enumerate(self.pages) if p.param == action.param),
631
700
  None,
632
701
  )
633
- if idx is None:
634
- return None
635
702
 
636
703
  page = await action.fetch_fn(action.param)
637
- self.pages[idx] = Page(page, action.param)
704
+
705
+ if idx is None:
706
+ # Page doesn't exist - jump to this page, clearing existing pages
707
+ self.pages.clear()
708
+ self.pages.append(Page(page, action.param))
709
+ else:
710
+ # Page exists, update it
711
+ self.pages[idx] = Page(page, action.param)
712
+
638
713
  await self.commit()
639
714
  return page
640
715
 
@@ -708,7 +783,10 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
708
783
  cancel_fetch: bool = False,
709
784
  ) -> ActionResult[T | None]:
710
785
  """
711
- Refetch an existing page by its param. Queued for sequential execution.
786
+ Refetch a page by its param. Queued for sequential execution.
787
+
788
+ If the page doesn't exist, clears existing pages and loads the requested
789
+ page as the new starting point.
712
790
 
713
791
  Note: Prefer calling refetch_page() on InfiniteQueryResult to ensure the
714
792
  correct fetch function is used. When called directly on InfiniteQuery, uses
@@ -725,6 +803,9 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
725
803
  self._cancel_queue()
726
804
  if self._queue_task and not self._queue_task.done():
727
805
  self._queue_task.cancel()
806
+ if self._interval_effect is not None:
807
+ self._interval_effect.dispose()
808
+ self._interval_effect = None
728
809
  if self.cfg.on_dispose:
729
810
  self.cfg.on_dispose(self)
730
811
 
@@ -778,7 +859,6 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
778
859
  _on_success: Callable[[list[Page[T, TParam]]], Awaitable[None] | None] | None
779
860
  _on_error: Callable[[Exception], Awaitable[None] | None] | None
780
861
  _observe_effect: Effect
781
- _interval_effect: Effect | None
782
862
  _data_computed: Computed[list[Page[T, TParam]] | None]
783
863
  _enabled: Signal[bool]
784
864
  _fetch_on_mount: bool
@@ -801,13 +881,17 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
801
881
  self._fetch_fn = fetch_fn
802
882
  self._stale_time = stale_time
803
883
  self._gc_time = gc_time
804
- self._refetch_interval = refetch_interval
884
+ interval = (
885
+ refetch_interval
886
+ if refetch_interval is not None and refetch_interval > 0
887
+ else None
888
+ )
889
+ self._refetch_interval = interval
805
890
  self._keep_previous_data = keep_previous_data
806
891
  self._on_success = on_success
807
892
  self._on_error = on_error
808
893
  self._enabled = Signal(enabled, name=f"inf_query.enabled({query().key})")
809
894
  self._fetch_on_mount = fetch_on_mount
810
- self._interval_effect = None
811
895
 
812
896
  def observe_effect():
813
897
  q = self._query()
@@ -816,8 +900,13 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
816
900
  with Untrack():
817
901
  q.observe(self)
818
902
 
819
- if enabled and fetch_on_mount and self.is_stale():
820
- q.invalidate()
903
+ # Skip if query interval is active - interval effect handles initial fetch
904
+ if enabled and fetch_on_mount and not q.has_interval:
905
+ # Fetch if no data loaded yet or if existing data is stale
906
+ if not q.is_fetching() and (
907
+ q.status() == "loading" or self.is_stale()
908
+ ):
909
+ q.invalidate()
821
910
 
822
911
  # Return cleanup function that captures the query (old query on key change)
823
912
  def cleanup():
@@ -834,25 +923,6 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
834
923
  self._data_computed_fn, name=f"inf_query_data({self._query().key})"
835
924
  )
836
925
 
837
- # Set up interval effect if interval is specified
838
- if refetch_interval is not None and refetch_interval > 0:
839
- self._setup_interval_effect(refetch_interval)
840
-
841
- def _setup_interval_effect(self, interval: float):
842
- """Create an effect that invalidates the query at the specified interval."""
843
-
844
- def interval_fn():
845
- # Read enabled to make this effect reactive to enabled changes
846
- if self._enabled():
847
- self._query().invalidate()
848
-
849
- self._interval_effect = Effect(
850
- interval_fn,
851
- name=f"inf_query_interval({self._query().key})",
852
- interval=interval,
853
- immediate=True,
854
- )
855
-
856
926
  @property
857
927
  def status(self) -> QueryStatus:
858
928
  return self._query().status()
@@ -985,15 +1055,20 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
985
1055
  async def wait(self) -> ActionResult[list[Page[T, TParam]]]:
986
1056
  return await self._query().wait(fetch_fn=self._fetch_fn, observer=self)
987
1057
 
1058
+ async def ensure(self) -> ActionResult[list[Page[T, TParam]]]:
1059
+ return await self._query().ensure(fetch_fn=self._fetch_fn, observer=self)
1060
+
988
1061
  def invalidate(self):
989
1062
  query = self._query()
990
1063
  query.invalidate(fetch_fn=self._fetch_fn, observer=self)
991
1064
 
992
1065
  def enable(self):
993
1066
  self._enabled.write(True)
1067
+ self._query()._update_interval() # pyright: ignore[reportPrivateUsage]
994
1068
 
995
1069
  def disable(self):
996
1070
  self._enabled.write(False)
1071
+ self._query()._update_interval() # pyright: ignore[reportPrivateUsage]
997
1072
 
998
1073
  def set_error(self, error: Exception):
999
1074
  query = self._query()
@@ -1002,8 +1077,6 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
1002
1077
  @override
1003
1078
  def dispose(self):
1004
1079
  """Clean up the result and its observe effect."""
1005
- if self._interval_effect is not None:
1006
- self._interval_effect.dispose()
1007
1080
  self._observe_effect.dispose()
1008
1081
 
1009
1082
 
@@ -90,6 +90,15 @@ class QueryResult(Protocol[T]):
90
90
  """
91
91
  ...
92
92
 
93
+ async def ensure(self) -> ActionResult[T]:
94
+ """
95
+ Ensure an initial fetch has started, then wait for completion.
96
+
97
+ Returns:
98
+ ActionResult containing either the data or an error.
99
+ """
100
+ ...
101
+
93
102
  def invalidate(self) -> None:
94
103
  """Mark the query as stale and trigger a refetch if observed."""
95
104
  ...
@@ -268,6 +268,9 @@ class KeyedQuery(Generic[T], Disposable):
268
268
  _task: asyncio.Task[None] | None
269
269
  _task_initiator: "KeyedQueryResult[T] | None"
270
270
  _gc_handle: asyncio.TimerHandle | None
271
+ _interval_effect: Effect | None
272
+ _interval: float | None
273
+ _interval_observer: "KeyedQueryResult[T] | None"
271
274
 
272
275
  def __init__(
273
276
  self,
@@ -293,6 +296,9 @@ class KeyedQuery(Generic[T], Disposable):
293
296
  self._task = None
294
297
  self._task_initiator = None
295
298
  self._gc_handle = None
299
+ self._interval_effect = None
300
+ self._interval = None
301
+ self._interval_observer = None
296
302
 
297
303
  # --- Delegate signal access to state ---
298
304
  @property
@@ -438,6 +444,66 @@ class KeyedQuery(Generic[T], Disposable):
438
444
  )
439
445
  return self.observers[0]._fetch_fn # pyright: ignore[reportPrivateUsage]
440
446
 
447
+ @property
448
+ def has_interval(self) -> bool:
449
+ return self._interval is not None
450
+
451
+ def _select_interval_observer(
452
+ self,
453
+ ) -> tuple[float | None, "KeyedQueryResult[T] | None"]:
454
+ min_interval: float | None = None
455
+ selected: "KeyedQueryResult[T] | None" = None
456
+
457
+ for obs in reversed(self.observers):
458
+ interval = obs._refetch_interval # pyright: ignore[reportPrivateUsage]
459
+ if interval is None:
460
+ continue
461
+ if not obs._enabled.value: # pyright: ignore[reportPrivateUsage]
462
+ continue
463
+ if min_interval is None or interval < min_interval:
464
+ min_interval = interval
465
+ selected = obs
466
+
467
+ return min_interval, selected
468
+
469
+ def _create_interval_effect(self, interval: float) -> Effect:
470
+ def interval_fn():
471
+ observer = self._interval_observer
472
+ if observer is None:
473
+ return
474
+ if not self.is_scheduled and len(self.observers) > 0:
475
+ self.run_fetch(
476
+ observer._fetch_fn, # pyright: ignore[reportPrivateUsage]
477
+ cancel_previous=False,
478
+ initiator=observer,
479
+ )
480
+
481
+ return Effect(
482
+ interval_fn,
483
+ name=f"query_interval({self.key})",
484
+ interval=interval,
485
+ immediate=True,
486
+ )
487
+
488
+ def _update_interval(self) -> None:
489
+ new_interval, new_observer = self._select_interval_observer()
490
+ interval_changed = new_interval != self._interval
491
+
492
+ self._interval = new_interval
493
+ self._interval_observer = new_observer
494
+
495
+ if not interval_changed:
496
+ if self._interval_effect is None and new_interval is not None:
497
+ self._interval_effect = self._create_interval_effect(new_interval)
498
+ return
499
+
500
+ if self._interval_effect is not None:
501
+ self._interval_effect.dispose()
502
+ self._interval_effect = None
503
+
504
+ if new_interval is not None:
505
+ self._interval_effect = self._create_interval_effect(new_interval)
506
+
441
507
  async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
442
508
  """
443
509
  Reruns the query and returns the result.
@@ -468,11 +534,13 @@ class KeyedQuery(Generic[T], Disposable):
468
534
  self.cancel_gc()
469
535
  if observer._gc_time > 0: # pyright: ignore[reportPrivateUsage]
470
536
  self.cfg.gc_time = max(self.cfg.gc_time, observer._gc_time) # pyright: ignore[reportPrivateUsage]
537
+ self._update_interval()
471
538
 
472
539
  def unobserve(self, observer: "KeyedQueryResult[T]"):
473
540
  """Unregister an observer. Schedules GC if no observers remain."""
474
541
  if observer in self.observers:
475
542
  self.observers.remove(observer)
543
+ self._update_interval()
476
544
 
477
545
  # If the departing observer initiated the ongoing fetch, cancel it
478
546
  if self._task_initiator is observer and self._task and not self._task.done():
@@ -505,6 +573,9 @@ class KeyedQuery(Generic[T], Disposable):
505
573
  def dispose(self):
506
574
  """Clean up the query, cancelling any in-flight fetch."""
507
575
  self.cancel()
576
+ if self._interval_effect is not None:
577
+ self._interval_effect.dispose()
578
+ self._interval_effect = None
508
579
  if self.cfg.on_dispose:
509
580
  self.cfg.on_dispose(self)
510
581
 
@@ -559,7 +630,12 @@ class UnkeyedQueryResult(Generic[T], Disposable):
559
630
  self._on_success = on_success
560
631
  self._on_error = on_error
561
632
  self._stale_time = stale_time
562
- self._refetch_interval = refetch_interval
633
+ interval = (
634
+ refetch_interval
635
+ if refetch_interval is not None and refetch_interval > 0
636
+ else None
637
+ )
638
+ self._refetch_interval = interval
563
639
  self._keep_previous_data = keep_previous_data
564
640
  self._enabled = Signal(enabled, name="query.enabled(unkeyed)")
565
641
  self._interval_effect = None
@@ -581,12 +657,13 @@ class UnkeyedQueryResult(Generic[T], Disposable):
581
657
 
582
658
  # Schedule initial fetch if stale (untracked to avoid reactive loop)
583
659
  with Untrack():
584
- if enabled and fetch_on_mount and self.is_stale():
660
+ # Skip if refetch_interval is active - interval effect handles initial fetch
661
+ if enabled and fetch_on_mount and interval is None and self.is_stale():
585
662
  self.schedule()
586
663
 
587
664
  # Set up interval effect if interval is specified
588
- if refetch_interval is not None and refetch_interval > 0:
589
- self._setup_interval_effect(refetch_interval)
665
+ if interval is not None:
666
+ self._setup_interval_effect(interval)
590
667
 
591
668
  def _setup_interval_effect(self, interval: float):
592
669
  """Create an effect that invalidates the query at the specified interval."""
@@ -699,15 +776,18 @@ class UnkeyedQueryResult(Generic[T], Disposable):
699
776
  return await self.wait()
700
777
 
701
778
  async def wait(self) -> ActionResult[T]:
702
- """Wait for the current query to complete."""
703
- # If loading and no task, schedule a fetch
704
- if self.state.status() == "loading" and not self.state.is_fetching():
705
- self.schedule()
779
+ """Wait for the current in-flight fetch to complete."""
706
780
  await self._effect.wait()
707
781
  if self.state.status() == "error":
708
782
  return ActionError(cast(Exception, self.state.error.read()))
709
783
  return ActionSuccess(cast(T, self.state.data.read()))
710
784
 
785
+ async def ensure(self) -> ActionResult[T]:
786
+ """Ensure an initial fetch has started, then wait for completion."""
787
+ if self.state.status() == "loading" and not self.state.is_fetching():
788
+ self.schedule()
789
+ return await self.wait()
790
+
711
791
  def invalidate(self):
712
792
  """Mark the query as stale and refetch through the effect."""
713
793
  if not self.is_scheduled:
@@ -740,7 +820,6 @@ class KeyedQueryResult(Generic[T], Disposable):
740
820
  _on_success: Callable[[T], Awaitable[None] | None] | None
741
821
  _on_error: Callable[[Exception], Awaitable[None] | None] | None
742
822
  _observe_effect: Effect
743
- _interval_effect: Effect | None
744
823
  _data_computed: Computed[T | None]
745
824
  _enabled: Signal[bool]
746
825
  _fetch_on_mount: bool
@@ -762,12 +841,16 @@ class KeyedQueryResult(Generic[T], Disposable):
762
841
  self._fetch_fn = fetch_fn
763
842
  self._stale_time = stale_time
764
843
  self._gc_time = gc_time
765
- self._refetch_interval = refetch_interval
844
+ interval = (
845
+ refetch_interval
846
+ if refetch_interval is not None and refetch_interval > 0
847
+ else None
848
+ )
849
+ self._refetch_interval = interval
766
850
  self._keep_previous_data = keep_previous_data
767
851
  self._on_success = on_success
768
852
  self._on_error = on_error
769
853
  self._enabled = Signal(enabled, name=f"query.enabled({query().key})")
770
- self._interval_effect = None
771
854
 
772
855
  def observe_effect():
773
856
  q = self._query()
@@ -776,9 +859,11 @@ class KeyedQueryResult(Generic[T], Disposable):
776
859
  with Untrack():
777
860
  q.observe(self)
778
861
 
779
- # If stale or loading, schedule refetch (only when enabled)
780
- if enabled and fetch_on_mount and self.is_stale():
781
- self.invalidate()
862
+ # Skip if query interval is active - interval effect handles initial fetch
863
+ if enabled and fetch_on_mount and not q.has_interval:
864
+ # If stale, schedule refetch (only when enabled)
865
+ if not q.is_fetching() and self.is_stale():
866
+ self.invalidate()
782
867
 
783
868
  # Return cleanup function that captures the query (old query on key change)
784
869
  def cleanup():
@@ -795,25 +880,6 @@ class KeyedQueryResult(Generic[T], Disposable):
795
880
  self._data_computed_fn, name=f"query_data({self._query().key})"
796
881
  )
797
882
 
798
- # Set up interval effect if interval is specified
799
- if refetch_interval is not None and refetch_interval > 0:
800
- self._setup_interval_effect(refetch_interval)
801
-
802
- def _setup_interval_effect(self, interval: float):
803
- """Create an effect that invalidates the query at the specified interval."""
804
-
805
- def interval_fn():
806
- # Read enabled to make this effect reactive to enabled changes
807
- if self._enabled():
808
- self.invalidate()
809
-
810
- self._interval_effect = Effect(
811
- interval_fn,
812
- name=f"query_interval({self._query().key})",
813
- interval=interval,
814
- immediate=True,
815
- )
816
-
817
883
  @property
818
884
  def status(self) -> QueryStatus:
819
885
  return self._query().status()
@@ -874,9 +940,12 @@ class KeyedQueryResult(Generic[T], Disposable):
874
940
  return await self.wait()
875
941
 
876
942
  async def wait(self) -> ActionResult[T]:
877
- """Wait for the current query to complete."""
943
+ """Wait for the current in-flight fetch to complete."""
944
+ return await self._query().wait()
945
+
946
+ async def ensure(self) -> ActionResult[T]:
947
+ """Ensure an initial fetch has started, then wait for completion."""
878
948
  query = self._query()
879
- # If loading and no task, start a fetch with this observer's fetch function
880
949
  if query.status() == "loading" and not query.is_fetching():
881
950
  query.run_fetch(self._fetch_fn, initiator=self)
882
951
  return await query.wait()
@@ -910,16 +979,16 @@ class KeyedQueryResult(Generic[T], Disposable):
910
979
  def enable(self):
911
980
  """Enable the query."""
912
981
  self._enabled.write(True)
982
+ self._query()._update_interval() # pyright: ignore[reportPrivateUsage]
913
983
 
914
984
  def disable(self):
915
985
  """Disable the query, preventing it from fetching."""
916
986
  self._enabled.write(False)
987
+ self._query()._update_interval() # pyright: ignore[reportPrivateUsage]
917
988
 
918
989
  @override
919
990
  def dispose(self):
920
991
  """Clean up the result and its observe effect."""
921
- if self._interval_effect is not None and not self._interval_effect.__disposed__:
922
- self._interval_effect.dispose()
923
992
  if not self._observe_effect.__disposed__:
924
993
  self._observe_effect.dispose()
925
994
 
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ _REQUIREMENTS: list[tuple[str, str]] = []
6
+
7
+
8
+ def add_requirement(name: str, version: str) -> None:
9
+ if not name or not version:
10
+ return
11
+ _REQUIREMENTS.append((name, version))
12
+
13
+
14
+ def register_requirements(packages: Mapping[str, str]) -> None:
15
+ for name, version in packages.items():
16
+ if not name or not version:
17
+ continue
18
+ add_requirement(name, version)
19
+
20
+
21
+ def get_requirements() -> list[tuple[str, str]]:
22
+ return list(_REQUIREMENTS)
23
+
24
+
25
+ def clear_requirements() -> None:
26
+ _REQUIREMENTS.clear()
27
+
28
+
29
+ def require(packages: Mapping[str, str]) -> None:
30
+ """Register npm package version requirements for dependency syncing."""
31
+ if not isinstance(packages, Mapping):
32
+ raise TypeError("require expects a mapping of package names to versions")
33
+ if not packages:
34
+ return
35
+
36
+ normalized: dict[str, str] = {}
37
+ for name, version in packages.items():
38
+ if not isinstance(name, str) or not name.strip():
39
+ raise TypeError("require expects non-empty package names")
40
+ if not isinstance(version, str) or not version.strip():
41
+ raise TypeError(f"require expects a version string for {name!r}")
42
+ normalized[name.strip()] = version.strip()
43
+
44
+ register_requirements(normalized)
45
+
46
+
47
+ __all__ = ["require"]