pulse-framework 0.1.63__tar.gz → 0.1.65__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.63 → pulse_framework-0.1.65}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/__init__.py +16 -10
  4. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/app.py +30 -11
  5. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/channel.py +3 -3
  6. pulse_framework-0.1.63/src/pulse/form.py → pulse_framework-0.1.65/src/pulse/forms.py +2 -2
  7. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/helpers.py +9 -212
  8. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/proxy.py +10 -3
  9. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/client.py +5 -1
  10. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/effect.py +2 -1
  11. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/infinite_query.py +164 -54
  12. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/protocol.py +9 -0
  13. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/query.py +164 -81
  14. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/store.py +10 -2
  15. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/reactive.py +18 -7
  16. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/render_session.py +61 -12
  17. pulse_framework-0.1.65/src/pulse/scheduling.py +448 -0
  18. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/README.md +0 -0
  19. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/_examples.py +0 -0
  20. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/__init__.py +0 -0
  21. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/cmd.py +0 -0
  22. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/dependencies.py +0 -0
  23. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/folder_lock.py +0 -0
  24. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/helpers.py +0 -0
  25. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/logging.py +0 -0
  26. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/models.py +0 -0
  27. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/packages.py +0 -0
  28. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/processes.py +0 -0
  29. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/secrets.py +0 -0
  30. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cli/uvicorn_log_config.py +0 -0
  31. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/code_analysis.py +0 -0
  32. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/__init__.py +0 -0
  33. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/codegen.py +0 -0
  34. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/templates/__init__.py +0 -0
  35. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/templates/layout.py +0 -0
  36. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/templates/route.py +0 -0
  37. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/templates/routes_ts.py +0 -0
  38. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/codegen/utils.py +0 -0
  39. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/component.py +0 -0
  40. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/components/__init__.py +0 -0
  41. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/components/for_.py +0 -0
  42. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/components/if_.py +0 -0
  43. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/components/react_router.py +0 -0
  44. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/context.py +0 -0
  45. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/cookies.py +0 -0
  46. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/decorators.py +0 -0
  47. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/__init__.py +0 -0
  48. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/elements.py +0 -0
  49. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/events.py +0 -0
  50. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/props.py +0 -0
  51. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/svg.py +0 -0
  52. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/tags.py +0 -0
  53. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/dom/tags.pyi +0 -0
  54. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/env.py +0 -0
  55. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/__init__.py +0 -0
  56. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/core.py +0 -0
  57. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/effects.py +0 -0
  58. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/init.py +0 -0
  59. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/runtime.py +0 -0
  60. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/setup.py +0 -0
  61. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/stable.py +0 -0
  62. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/hooks/state.py +0 -0
  63. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/__init__.py +0 -0
  64. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/__init__.pyi +0 -0
  65. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/_types.py +0 -0
  66. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/array.py +0 -0
  67. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/console.py +0 -0
  68. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/date.py +0 -0
  69. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/document.py +0 -0
  70. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/error.py +0 -0
  71. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/json.py +0 -0
  72. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/map.py +0 -0
  73. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/math.py +0 -0
  74. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/navigator.py +0 -0
  75. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/number.py +0 -0
  76. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/obj.py +0 -0
  77. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/object.py +0 -0
  78. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/promise.py +0 -0
  79. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/pulse.py +0 -0
  80. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/react.py +0 -0
  81. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/regexp.py +0 -0
  82. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/set.py +0 -0
  83. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/string.py +0 -0
  84. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/weakmap.py +0 -0
  85. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/weakset.py +0 -0
  86. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/js/window.py +0 -0
  87. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/messages.py +0 -0
  88. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/middleware.py +0 -0
  89. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/plugin.py +0 -0
  90. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/py.typed +0 -0
  91. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/__init__.py +0 -0
  92. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/common.py +0 -0
  93. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/queries/mutation.py +0 -0
  94. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/react_component.py +0 -0
  95. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/reactive_extensions.py +0 -0
  96. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/renderer.py +0 -0
  97. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/request.py +0 -0
  98. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/requirements.py +0 -0
  99. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/routing.py +0 -0
  100. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/serializer.py +0 -0
  101. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/state.py +0 -0
  102. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/test_helpers.py +0 -0
  103. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/__init__.py +0 -0
  104. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/assets.py +0 -0
  105. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/builtins.py +0 -0
  106. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/dynamic_import.py +0 -0
  107. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/emit_context.py +0 -0
  108. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/errors.py +0 -0
  109. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/function.py +0 -0
  110. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/id.py +0 -0
  111. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/imports.py +0 -0
  112. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/js_module.py +0 -0
  113. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/__init__.py +0 -0
  114. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/asyncio.py +0 -0
  115. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/json.py +0 -0
  116. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/math.py +0 -0
  117. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  118. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  119. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/modules/typing.py +0 -0
  120. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/nodes.py +0 -0
  121. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/py_module.py +0 -0
  122. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/transpiler.py +0 -0
  123. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/transpiler/vdom.py +0 -0
  124. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/types/__init__.py +0 -0
  125. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/types/event_handler.py +0 -0
  126. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/src/pulse/user_session.py +0 -0
  127. {pulse_framework-0.1.63 → pulse_framework-0.1.65}/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.63
3
+ Version: 0.1.65
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.63"
3
+ version = "0.1.65"
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"
@@ -1134,16 +1134,16 @@ from pulse.env import env as env
1134
1134
  from pulse.env import mode as mode
1135
1135
 
1136
1136
  # Forms
1137
- from pulse.form import (
1137
+ from pulse.forms import (
1138
1138
  Form as Form,
1139
1139
  )
1140
- from pulse.form import (
1140
+ from pulse.forms import (
1141
1141
  FormData as FormData,
1142
1142
  )
1143
- from pulse.form import (
1143
+ from pulse.forms import (
1144
1144
  FormValue as FormValue,
1145
1145
  )
1146
- from pulse.form import (
1146
+ from pulse.forms import (
1147
1147
  ManualForm as ManualForm,
1148
1148
  )
1149
1149
 
@@ -1151,12 +1151,6 @@ from pulse.form import (
1151
1151
  from pulse.helpers import (
1152
1152
  CSSProperties as CSSProperties,
1153
1153
  )
1154
- from pulse.helpers import (
1155
- later as later,
1156
- )
1157
- from pulse.helpers import (
1158
- repeat as repeat,
1159
- )
1160
1154
 
1161
1155
  # Hooks - Core
1162
1156
  from pulse.hooks.core import (
@@ -1414,6 +1408,18 @@ from pulse.requirements import require as require
1414
1408
  from pulse.routing import Layout as Layout
1415
1409
  from pulse.routing import Route as Route
1416
1410
  from pulse.routing import RouteInfo as RouteInfo
1411
+ from pulse.scheduling import (
1412
+ TaskRegistry as TaskRegistry,
1413
+ )
1414
+ from pulse.scheduling import (
1415
+ TimerRegistry as TimerRegistry,
1416
+ )
1417
+ from pulse.scheduling import (
1418
+ later as later,
1419
+ )
1420
+ from pulse.scheduling import (
1421
+ repeat as repeat,
1422
+ )
1417
1423
  from pulse.serializer import deserialize as deserialize
1418
1424
 
1419
1425
  # Serializer
@@ -5,7 +5,6 @@ This module provides the main App class that users instantiate in their main.py
5
5
  to define routes and configure their Pulse application.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import logging
10
9
  import os
11
10
  from collections import defaultdict
@@ -40,11 +39,9 @@ from pulse.env import (
40
39
  )
41
40
  from pulse.env import env as envvars
42
41
  from pulse.helpers import (
43
- create_task,
44
42
  find_available_port,
45
43
  get_client_address,
46
44
  get_client_address_socketio,
47
- later,
48
45
  )
49
46
  from pulse.hooks.core import hooks
50
47
  from pulse.messages import (
@@ -74,6 +71,7 @@ from pulse.proxy import ReactProxy
74
71
  from pulse.render_session import RenderSession
75
72
  from pulse.request import PulseRequest
76
73
  from pulse.routing import Layout, Route, RouteTree, ensure_absolute_path
74
+ from pulse.scheduling import TaskRegistry, TimerHandleLike, TimerRegistry
77
75
  from pulse.serializer import Serialized, deserialize, serialize
78
76
  from pulse.user_session import (
79
77
  CookieSessionStore,
@@ -209,7 +207,10 @@ class App:
209
207
  _render_to_user: dict[str, str]
210
208
  _sessions_in_request: dict[str, int]
211
209
  _socket_to_render: dict[str, str]
212
- _render_cleanups: dict[str, asyncio.TimerHandle]
210
+ _render_cleanups: dict[str, TimerHandleLike]
211
+ _tasks: TaskRegistry
212
+ _timers: TimerRegistry
213
+ _proxy: ReactProxy | None
213
214
  session_timeout: float
214
215
  connection_status: ConnectionStatusConfig
215
216
  render_loop_limit: int
@@ -283,6 +284,9 @@ class App:
283
284
  self._socket_to_render = {}
284
285
  # Map render_id -> cleanup timer handle for timeout-based expiry
285
286
  self._render_cleanups = {}
287
+ self._tasks = TaskRegistry(name="app")
288
+ self._timers = TimerRegistry(tasks=self._tasks, name="app")
289
+ self._proxy = None
286
290
  self.session_timeout = session_timeout
287
291
  self.detach_queue_timeout = detach_queue_timeout
288
292
  self.disconnect_queue_timeout = disconnect_queue_timeout
@@ -659,10 +663,11 @@ class App:
659
663
  + "Use 'pulse run' CLI command or set the environment variable."
660
664
  )
661
665
 
662
- proxy_handler = ReactProxy(
666
+ self._proxy = ReactProxy(
663
667
  react_server_address=react_server_address,
664
668
  server_address=server_address,
665
669
  )
670
+ proxy_handler = self._proxy
666
671
 
667
672
  # In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
668
673
  # Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
@@ -718,7 +723,7 @@ class App:
718
723
  payload = serialize(message)
719
724
  # `serialize` returns a tuple, which socket.io will mistake for multiple arguments
720
725
  payload = list(payload)
721
- create_task(self.sio.emit("message", list(payload), to=sid))
726
+ self._tasks.create_task(self.sio.emit("message", list(payload), to=sid))
722
727
 
723
728
  render.connect(on_message)
724
729
  # Map socket sid to renderId for message routing
@@ -790,8 +795,10 @@ class App:
790
795
  def _cancel_render_cleanup(self, rid: str):
791
796
  """Cancel any pending cleanup task for a render session."""
792
797
  cleanup_handle = self._render_cleanups.pop(rid, None)
793
- if cleanup_handle and not cleanup_handle.cancelled():
794
- cleanup_handle.cancel()
798
+ if cleanup_handle:
799
+ if not cleanup_handle.cancelled():
800
+ cleanup_handle.cancel()
801
+ self._timers.discard(cleanup_handle)
795
802
 
796
803
  def _schedule_render_cleanup(self, rid: str):
797
804
  """Schedule cleanup of a RenderSession after the configured timeout."""
@@ -817,7 +824,7 @@ class App:
817
824
  )
818
825
  self.close_render(rid)
819
826
 
820
- handle = later(self.session_timeout, _cleanup)
827
+ handle = self._timers.later(self.session_timeout, _cleanup)
821
828
  self._render_cleanups[rid] = handle
822
829
 
823
830
  async def _handle_pulse_message(
@@ -1023,7 +1030,7 @@ class App:
1023
1030
  self._user_to_render[session.sid].remove(rid)
1024
1031
 
1025
1032
  if len(self._user_to_render[session.sid]) == 0:
1026
- later(60, self.close_session_if_inactive, sid)
1033
+ self._timers.later(60, self.close_session_if_inactive, sid)
1027
1034
 
1028
1035
  def close_session(self, sid: str):
1029
1036
  session = self.user_sessions.pop(sid, None)
@@ -1053,6 +1060,15 @@ class App:
1053
1060
  for sid in list(self.user_sessions.keys()):
1054
1061
  self.close_session(sid)
1055
1062
 
1063
+ # Cancel any remaining app-level tasks/timers
1064
+ self._tasks.cancel_all()
1065
+ self._timers.cancel_all()
1066
+ if self._proxy is not None:
1067
+ try:
1068
+ await self._proxy.close()
1069
+ except Exception:
1070
+ logger.exception("Error during ReactProxy.close()")
1071
+
1056
1072
  # Update status
1057
1073
  self.status = AppStatus.stopped
1058
1074
  # Call plugin on_shutdown hooks before closing
@@ -1082,5 +1098,8 @@ class App:
1082
1098
  return # no active render for this user session
1083
1099
 
1084
1100
  # We don't want to wait for this to resolve
1085
- create_task(render.call_api(f"{self.api_prefix}/set-cookies", method="GET"))
1101
+ render.create_task(
1102
+ render.call_api(f"{self.api_prefix}/set-cookies", method="GET"),
1103
+ name="cookies.refresh",
1104
+ )
1086
1105
  sess.scheduled_cookie_refresh = True
@@ -7,7 +7,6 @@ from dataclasses import dataclass
7
7
  from typing import TYPE_CHECKING, Any, cast
8
8
 
9
9
  from pulse.context import PulseContext
10
- from pulse.helpers import create_future_on_loop
11
10
  from pulse.messages import (
12
11
  ClientChannelRequestMessage,
13
12
  ClientChannelResponseMessage,
@@ -15,6 +14,7 @@ from pulse.messages import (
15
14
  ServerChannelRequestMessage,
16
15
  ServerChannelResponseMessage,
17
16
  )
17
+ from pulse.scheduling import create_future
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from pulse.render_session import RenderSession
@@ -203,7 +203,7 @@ class ChannelsManager:
203
203
  msg=msg,
204
204
  )
205
205
 
206
- asyncio.create_task(_invoke())
206
+ render.create_task(_invoke(), name=f"channel:{channel_id}:{event}")
207
207
 
208
208
  # ------------------------------------------------------------------
209
209
  def register_pending(
@@ -494,7 +494,7 @@ class Channel:
494
494
 
495
495
  self._ensure_open()
496
496
  request_id = uuid.uuid4().hex
497
- fut = create_future_on_loop()
497
+ fut = create_future()
498
498
  self._manager.register_pending(request_id, fut, self.id)
499
499
  msg = ServerChannelRequestMessage(
500
500
  type="channel_message",
@@ -244,7 +244,7 @@ def Form(
244
244
  key: str,
245
245
  onSubmit: EventHandler1[FormData] | None = None,
246
246
  **props: Unpack[PulseFormProps], # pyright: ignore[reportGeneralTypeIssues]
247
- ) -> Node:
247
+ ):
248
248
  """Server-registered HTML form component.
249
249
 
250
250
  Automatically wires up form submission to a Python handler. Uses
@@ -435,7 +435,7 @@ class ManualForm(Disposable):
435
435
  *children: Node,
436
436
  key: str | None = None,
437
437
  **props: Unpack[PulseFormProps],
438
- ) -> Node:
438
+ ):
439
439
  """Render as a form element with children.
440
440
 
441
441
  Args:
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  import inspect
3
2
  import linecache
4
3
  import os
@@ -17,7 +16,6 @@ from typing import (
17
16
  )
18
17
  from urllib.parse import urlsplit
19
18
 
20
- from anyio import from_thread
21
19
  from fastapi import Request
22
20
 
23
21
  from pulse.env import env
@@ -89,7 +87,15 @@ P = ParamSpec("P")
89
87
  CSSProperties = dict[str, Any]
90
88
 
91
89
 
92
- MISSING = object()
90
+ class Missing:
91
+ __slots__: tuple[str, ...] = ()
92
+
93
+ @override
94
+ def __repr__(self) -> str:
95
+ return "MISSING"
96
+
97
+
98
+ MISSING = Missing()
93
99
 
94
100
 
95
101
  class File(TypedDict):
@@ -130,7 +136,6 @@ def data(**attrs: Any):
130
136
  return {f"data-{k}": v for k, v in attrs.items()}
131
137
 
132
138
 
133
- # --- Async scheduling helpers (work from loop or sync threads) ---
134
139
  class Disposable(ABC):
135
140
  __disposed__: bool = False
136
141
 
@@ -158,214 +163,6 @@ class Disposable(ABC):
158
163
  cls.dispose = wrapped_dispose
159
164
 
160
165
 
161
- def is_pytest() -> bool:
162
- """Detect if running inside pytest using environment variables."""
163
- return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
164
- "PYTEST_XDIST_TESTRUNUID" in os.environ
165
- )
166
-
167
-
168
- def schedule_on_loop(callback: Callable[[], None]) -> None:
169
- """Schedule a callback to run ASAP on the main event loop from any thread."""
170
- try:
171
- loop = asyncio.get_running_loop()
172
- loop.call_soon_threadsafe(callback)
173
- except RuntimeError:
174
-
175
- async def _runner():
176
- loop = asyncio.get_running_loop()
177
- loop.call_soon(callback)
178
-
179
- try:
180
- from_thread.run(_runner)
181
- except RuntimeError:
182
- if not is_pytest():
183
- raise
184
-
185
-
186
- def create_task(
187
- coroutine: Awaitable[T],
188
- *,
189
- name: str | None = None,
190
- on_done: Callable[[asyncio.Task[T]], None] | None = None,
191
- ) -> asyncio.Task[T]:
192
- """Create and schedule a coroutine task on the main loop from any thread.
193
-
194
- - factory should create a fresh coroutine each call
195
- - optional on_done is attached on the created task within the loop
196
- """
197
-
198
- try:
199
- asyncio.get_running_loop()
200
- # ensure_future accepts Awaitable and returns a Task when given a coroutine
201
- task = asyncio.ensure_future(coroutine)
202
- if name is not None:
203
- task.set_name(name)
204
- if on_done:
205
- task.add_done_callback(on_done)
206
- return task
207
- except RuntimeError:
208
-
209
- async def _runner():
210
- asyncio.get_running_loop()
211
- # ensure_future accepts Awaitable and returns a Task when given a coroutine
212
- task = asyncio.ensure_future(coroutine)
213
- if name is not None:
214
- task.set_name(name)
215
- if on_done:
216
- task.add_done_callback(on_done)
217
- return task
218
-
219
- try:
220
- return from_thread.run(_runner)
221
- except RuntimeError:
222
- if is_pytest():
223
- return None # pyright: ignore[reportReturnType]
224
- raise
225
-
226
-
227
- def create_future_on_loop() -> asyncio.Future[Any]:
228
- """Create an asyncio Future on the main event loop from any thread."""
229
- try:
230
- return asyncio.get_running_loop().create_future()
231
- except RuntimeError:
232
- from anyio import from_thread
233
-
234
- async def _create():
235
- loop = asyncio.get_running_loop()
236
- return loop.create_future()
237
-
238
- return from_thread.run(_create)
239
-
240
-
241
- def later(
242
- delay: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
243
- ) -> asyncio.TimerHandle:
244
- """
245
- Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
246
- Works with sync or async functions. Returns a TimerHandle; call .cancel() to cancel.
247
-
248
- The callback runs with no reactive scope to avoid accidentally capturing
249
- reactive dependencies from the calling context. Other context vars (like
250
- PulseContext) are preserved normally.
251
- """
252
-
253
- from pulse.reactive import Untrack
254
-
255
- try:
256
- loop = asyncio.get_running_loop()
257
- except RuntimeError:
258
- try:
259
- loop = asyncio.get_event_loop()
260
- except RuntimeError as exc:
261
- raise RuntimeError("later() requires an event loop") from exc
262
-
263
- def _run():
264
- try:
265
- with Untrack():
266
- res = fn(*args, **kwargs)
267
- if asyncio.iscoroutine(res):
268
- task = loop.create_task(res)
269
-
270
- def _log_task_exception(t: asyncio.Task[Any]):
271
- try:
272
- t.result()
273
- except asyncio.CancelledError:
274
- # Normal cancellation path
275
- pass
276
- except Exception as exc:
277
- loop.call_exception_handler(
278
- {
279
- "message": "Unhandled exception in later() task",
280
- "exception": exc,
281
- "context": {"callback": fn},
282
- }
283
- )
284
-
285
- task.add_done_callback(_log_task_exception)
286
- except Exception as exc:
287
- # Surface exceptions via the loop's exception handler and continue
288
- loop.call_exception_handler(
289
- {
290
- "message": "Unhandled exception in later() callback",
291
- "exception": exc,
292
- "context": {"callback": fn},
293
- }
294
- )
295
-
296
- return loop.call_later(delay, _run)
297
-
298
-
299
- class RepeatHandle:
300
- task: asyncio.Task[None] | None
301
- cancelled: bool
302
-
303
- def __init__(self) -> None:
304
- self.task = None
305
- self.cancelled = False
306
-
307
- def cancel(self):
308
- if self.cancelled:
309
- return
310
- self.cancelled = True
311
- if self.task is not None and not self.task.done():
312
- self.task.cancel()
313
-
314
-
315
- def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
316
- """
317
- Repeatedly run `fn(*args, **kwargs)` every `interval` seconds.
318
- Works with sync or async functions.
319
- For async functions, waits for completion before starting the next delay.
320
- Returns a handle with .cancel() to stop future runs.
321
-
322
- The callback runs with no reactive scope to avoid accidentally capturing
323
- reactive dependencies from the calling context. Other context vars (like
324
- PulseContext) are preserved normally.
325
-
326
- Optional kwargs:
327
- - immediate: bool = False # run once immediately before the first interval
328
- """
329
-
330
- from pulse.reactive import Untrack
331
-
332
- loop = asyncio.get_running_loop()
333
- handle = RepeatHandle()
334
-
335
- async def _runner():
336
- nonlocal handle
337
- try:
338
- while not handle.cancelled:
339
- # Start counting the next interval AFTER the previous execution completes
340
- await asyncio.sleep(interval)
341
- if handle.cancelled:
342
- break
343
- try:
344
- with Untrack():
345
- result = fn(*args, **kwargs)
346
- if asyncio.iscoroutine(result):
347
- await result
348
- except asyncio.CancelledError:
349
- # Propagate to outer handler to finish cleanly
350
- raise
351
- except Exception as exc:
352
- # Surface exceptions via the loop's exception handler and continue
353
- loop.call_exception_handler(
354
- {
355
- "message": "Unhandled exception in repeat() callback",
356
- "exception": exc,
357
- "context": {"callback": fn},
358
- }
359
- )
360
- except asyncio.CancelledError:
361
- # Swallow task cancellation to avoid noisy "exception was never retrieved"
362
- pass
363
-
364
- handle.task = loop.create_task(_runner())
365
-
366
- return handle
367
-
368
-
369
166
  def get_client_address(request: Request) -> str | None:
370
167
  """Best-effort client origin/address from an HTTP request.
371
168
 
@@ -9,7 +9,6 @@ from typing import cast
9
9
  import httpx
10
10
  import websockets
11
11
  from fastapi.responses import StreamingResponse
12
- from starlette.background import BackgroundTask
13
12
  from starlette.requests import Request
14
13
  from starlette.responses import PlainTextResponse, Response
15
14
  from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -223,9 +222,17 @@ class ReactProxy:
223
222
  v = self.rewrite_url(v)
224
223
  response_headers[k] = v
225
224
 
225
+ async def _iter():
226
+ try:
227
+ async for chunk in r.aiter_raw():
228
+ if await request.is_disconnected():
229
+ break
230
+ yield chunk
231
+ finally:
232
+ await r.aclose()
233
+
226
234
  return StreamingResponse(
227
- r.aiter_raw(),
228
- background=BackgroundTask(r.aclose),
235
+ _iter(),
229
236
  status_code=r.status_code,
230
237
  headers=response_headers,
231
238
  )
@@ -3,6 +3,7 @@ from collections.abc import Callable
3
3
  from typing import Any, TypeVar, overload
4
4
 
5
5
  from pulse.context import PulseContext
6
+ from pulse.helpers import MISSING
6
7
  from pulse.queries.common import ActionResult, QueryKey
7
8
  from pulse.queries.infinite_query import InfiniteQuery, Page
8
9
  from pulse.queries.query import KeyedQuery
@@ -203,7 +204,10 @@ class QueryClient:
203
204
  query = self.get(key)
204
205
  if query is None:
205
206
  return None
206
- return query.data.read()
207
+ value = query.data.read()
208
+ if value is MISSING:
209
+ return None
210
+ return value
207
211
 
208
212
  def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
209
213
  """Get the pages for an infinite query by key.
@@ -7,6 +7,7 @@ from typing import (
7
7
  override,
8
8
  )
9
9
 
10
+ from pulse.helpers import MISSING
10
11
  from pulse.reactive import AsyncEffect, Computed, Signal
11
12
 
12
13
 
@@ -49,7 +50,7 @@ class AsyncQueryEffect(AsyncEffect):
49
50
  # For unkeyed queries on re-run (dependency changed), reset data/status
50
51
  # to behave like keyed queries when key changes (new Query with data=None)
51
52
  if self._is_unkeyed and self.runs > 0:
52
- self.fetcher.data.write(None)
53
+ self.fetcher.data.write(MISSING)
53
54
  self.fetcher.status.write("loading")
54
55
 
55
56
  return super().run()