pulse-framework 0.1.54__tar.gz → 0.1.56__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 (138) hide show
  1. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/PKG-INFO +5 -5
  2. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/README.md +4 -4
  3. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/pyproject.toml +1 -1
  4. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/__init__.py +5 -6
  5. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/app.py +144 -57
  6. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/channel.py +139 -7
  7. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/cmd.py +16 -2
  8. pulse_framework-0.1.56/src/pulse/code_analysis.py +38 -0
  9. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/codegen.py +61 -62
  10. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/templates/route.py +100 -56
  11. pulse_framework-0.1.56/src/pulse/component.py +237 -0
  12. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/components/for_.py +30 -4
  13. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/components/if_.py +28 -5
  14. pulse_framework-0.1.56/src/pulse/components/react_router.py +96 -0
  15. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/context.py +39 -5
  16. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cookies.py +108 -4
  17. pulse_framework-0.1.56/src/pulse/decorators.py +344 -0
  18. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/env.py +56 -2
  19. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/form.py +198 -5
  20. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/helpers.py +7 -1
  21. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/hooks/core.py +135 -5
  22. pulse_framework-0.1.56/src/pulse/hooks/effects.py +88 -0
  23. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/hooks/init.py +60 -1
  24. pulse_framework-0.1.56/src/pulse/hooks/runtime.py +464 -0
  25. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/hooks/setup.py +77 -0
  26. pulse_framework-0.1.56/src/pulse/hooks/stable.py +138 -0
  27. pulse_framework-0.1.56/src/pulse/hooks/state.py +192 -0
  28. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/__init__.py +41 -25
  29. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/array.py +9 -6
  30. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/console.py +15 -12
  31. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/date.py +9 -6
  32. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/document.py +5 -2
  33. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/error.py +7 -4
  34. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/json.py +9 -6
  35. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/map.py +8 -5
  36. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/math.py +9 -6
  37. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/navigator.py +5 -2
  38. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/number.py +9 -6
  39. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/obj.py +16 -13
  40. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/object.py +9 -6
  41. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/promise.py +19 -13
  42. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/pulse.py +28 -25
  43. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/react.py +190 -44
  44. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/regexp.py +7 -4
  45. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/set.py +8 -5
  46. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/string.py +9 -6
  47. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/weakmap.py +8 -5
  48. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/weakset.py +8 -5
  49. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/window.py +6 -3
  50. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/messages.py +5 -0
  51. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/middleware.py +147 -76
  52. pulse_framework-0.1.56/src/pulse/plugin.py +96 -0
  53. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/client.py +186 -39
  54. pulse_framework-0.1.56/src/pulse/queries/common.py +101 -0
  55. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/infinite_query.py +154 -2
  56. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/mutation.py +127 -7
  57. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/query.py +112 -11
  58. pulse_framework-0.1.56/src/pulse/react_component.py +68 -0
  59. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/reactive.py +314 -30
  60. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/reactive_extensions.py +106 -26
  61. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/render_session.py +304 -173
  62. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/request.py +46 -11
  63. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/routing.py +140 -4
  64. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/serializer.py +71 -0
  65. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/state.py +177 -9
  66. pulse_framework-0.1.56/src/pulse/test_helpers.py +15 -0
  67. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/__init__.py +13 -3
  68. pulse_framework-0.1.56/src/pulse/transpiler/assets.py +66 -0
  69. pulse_framework-0.1.56/src/pulse/transpiler/dynamic_import.py +131 -0
  70. pulse_framework-0.1.56/src/pulse/transpiler/emit_context.py +49 -0
  71. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/function.py +6 -2
  72. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/imports.py +33 -27
  73. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/js_module.py +64 -8
  74. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/py_module.py +1 -7
  75. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/transpiler.py +4 -0
  76. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/user_session.py +119 -18
  77. pulse_framework-0.1.54/src/pulse/component.py +0 -115
  78. pulse_framework-0.1.54/src/pulse/components/react_router.py +0 -38
  79. pulse_framework-0.1.54/src/pulse/decorators.py +0 -175
  80. pulse_framework-0.1.54/src/pulse/hooks/effects.py +0 -104
  81. pulse_framework-0.1.54/src/pulse/hooks/runtime.py +0 -223
  82. pulse_framework-0.1.54/src/pulse/hooks/stable.py +0 -81
  83. pulse_framework-0.1.54/src/pulse/hooks/state.py +0 -105
  84. pulse_framework-0.1.54/src/pulse/js/react_dom.py +0 -30
  85. pulse_framework-0.1.54/src/pulse/plugin.py +0 -25
  86. pulse_framework-0.1.54/src/pulse/queries/common.py +0 -52
  87. pulse_framework-0.1.54/src/pulse/react_component.py +0 -5
  88. pulse_framework-0.1.54/src/pulse/transpiler/react_component.py +0 -51
  89. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/_examples.py +0 -0
  90. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/__init__.py +0 -0
  91. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/dependencies.py +0 -0
  92. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/folder_lock.py +0 -0
  93. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/helpers.py +0 -0
  94. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/logging.py +0 -0
  95. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/models.py +0 -0
  96. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/packages.py +0 -0
  97. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/processes.py +0 -0
  98. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/secrets.py +0 -0
  99. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/cli/uvicorn_log_config.py +0 -0
  100. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/__init__.py +0 -0
  101. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/js.py +0 -0
  102. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/templates/__init__.py +0 -0
  103. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/templates/layout.py +0 -0
  104. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/templates/routes_ts.py +0 -0
  105. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/codegen/utils.py +0 -0
  106. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/components/__init__.py +0 -0
  107. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/__init__.py +0 -0
  108. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/elements.py +0 -0
  109. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/events.py +0 -0
  110. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/props.py +0 -0
  111. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/svg.py +0 -0
  112. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/tags.py +0 -0
  113. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/dom/tags.pyi +0 -0
  114. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/hooks/__init__.py +0 -0
  115. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/__init__.pyi +0 -0
  116. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/js/_types.py +0 -0
  117. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/proxy.py +0 -0
  118. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/py.typed +0 -0
  119. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/__init__.py +0 -0
  120. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/effect.py +0 -0
  121. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/protocol.py +0 -0
  122. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/queries/store.py +0 -0
  123. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/renderer.py +0 -0
  124. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/builtins.py +0 -0
  125. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/errors.py +0 -0
  126. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/id.py +0 -0
  127. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/__init__.py +0 -0
  128. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/asyncio.py +0 -0
  129. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/json.py +0 -0
  130. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/math.py +0 -0
  131. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  132. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  133. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/modules/typing.py +0 -0
  134. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/nodes.py +0 -0
  135. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/transpiler/vdom.py +0 -0
  136. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/types/__init__.py +0 -0
  137. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/src/pulse/types/event_handler.py +0 -0
  138. {pulse_framework-0.1.54 → pulse_framework-0.1.56}/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.54
3
+ Version: 0.1.56
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -154,10 +154,10 @@ def counter():
154
154
 
155
155
  ### Hooks
156
156
 
157
- Server-side hooks via `ps.states`, `ps.effects`, `ps.setup`:
158
- - `states.use(initial)` - reactive state
159
- - `effects.use(fn, deps)` - side effects
160
- - `setup.use(fn)` - one-time initialization
157
+ Server-side hooks via `ps.state`, `ps.effect`, `ps.setup`:
158
+ - `ps.state(StateClass)` - reactive state (auto-keyed by callsite; use `key=` for manual control)
159
+ - `@ps.effect` - side effects decorator
160
+ - `ps.setup(fn)` - one-time initialization
161
161
 
162
162
  ### Queries
163
163
 
@@ -136,10 +136,10 @@ def counter():
136
136
 
137
137
  ### Hooks
138
138
 
139
- Server-side hooks via `ps.states`, `ps.effects`, `ps.setup`:
140
- - `states.use(initial)` - reactive state
141
- - `effects.use(fn, deps)` - side effects
142
- - `setup.use(fn)` - one-time initialization
139
+ Server-side hooks via `ps.state`, `ps.effect`, `ps.setup`:
140
+ - `ps.state(StateClass)` - reactive state (auto-keyed by callsite; use `key=` for manual control)
141
+ - `@ps.effect` - side effects decorator
142
+ - `ps.setup(fn)` - one-time initialization
143
143
 
144
144
  ### Queries
145
145
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.54"
3
+ version = "0.1.56"
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"
@@ -1205,9 +1205,8 @@ from pulse.hooks.core import (
1205
1205
  hooks as hooks,
1206
1206
  )
1207
1207
 
1208
- # Hooks - Effects
1209
- from pulse.hooks.effects import EffectsHookState as EffectsHookState
1210
- from pulse.hooks.effects import effects as effects
1208
+ # Hooks - Effects (import to register inline_effect_hook before registry locks)
1209
+ from pulse.hooks.effects import InlineEffectHookState as InlineEffectHookState
1211
1210
 
1212
1211
  # Hooks - Init
1213
1212
  from pulse.hooks.init import (
@@ -1323,9 +1322,6 @@ from pulse.middleware import (
1323
1322
  from pulse.middleware import (
1324
1323
  Redirect as Redirect,
1325
1324
  )
1326
- from pulse.middleware import (
1327
- RoutePrerenderResponse as RoutePrerenderResponse,
1328
- )
1329
1325
  from pulse.middleware import (
1330
1326
  stack as stack,
1331
1327
  )
@@ -1344,6 +1340,9 @@ from pulse.queries.infinite_query import infinite_query as infinite_query
1344
1340
  from pulse.queries.mutation import mutation as mutation
1345
1341
  from pulse.queries.protocol import QueryResult as QueryResult
1346
1342
  from pulse.queries.query import query as query
1343
+ from pulse.react_component import (
1344
+ ReactComponent as ReactComponent,
1345
+ )
1347
1346
 
1348
1347
  # React components (v2)
1349
1348
  from pulse.react_component import (
@@ -47,7 +47,6 @@ from pulse.helpers import (
47
47
  later,
48
48
  )
49
49
  from pulse.hooks.core import hooks
50
- from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
51
50
  from pulse.messages import (
52
51
  ClientChannelMessage,
53
52
  ClientChannelRequestMessage,
@@ -56,7 +55,9 @@ from pulse.messages import (
56
55
  ClientPulseMessage,
57
56
  Prerender,
58
57
  PrerenderPayload,
58
+ ServerInitMessage,
59
59
  ServerMessage,
60
+ ServerNavigateToMessage,
60
61
  )
61
62
  from pulse.middleware import (
62
63
  ConnectResponse,
@@ -67,13 +68,12 @@ from pulse.middleware import (
67
68
  PrerenderResponse,
68
69
  PulseMiddleware,
69
70
  Redirect,
70
- RoutePrerenderResponse,
71
71
  )
72
72
  from pulse.plugin import Plugin
73
73
  from pulse.proxy import ReactProxy
74
74
  from pulse.render_session import RenderSession
75
75
  from pulse.request import PulseRequest
76
- from pulse.routing import Layout, Route, RouteTree
76
+ from pulse.routing import Layout, Route, RouteTree, ensure_absolute_path
77
77
  from pulse.serializer import Serialized, deserialize, serialize
78
78
  from pulse.user_session import (
79
79
  CookieSessionStore,
@@ -88,6 +88,16 @@ T = TypeVar("T")
88
88
 
89
89
 
90
90
  class AppStatus(IntEnum):
91
+ """Application lifecycle status.
92
+
93
+ Attributes:
94
+ created: App instance created but not yet initialized.
95
+ initialized: App.setup() has been called, routes configured.
96
+ running: App is actively serving requests.
97
+ draining: App is shutting down, draining connections.
98
+ stopped: App has been fully stopped.
99
+ """
100
+
91
101
  created = 0
92
102
  initialized = 1
93
103
  running = 2
@@ -96,6 +106,12 @@ class AppStatus(IntEnum):
96
106
 
97
107
 
98
108
  PulseMode = Literal["subdomains", "single-server"]
109
+ """Deployment mode for the application.
110
+
111
+ Values:
112
+ "single-server": Python and React served from the same origin (default).
113
+ "subdomains": Python API on a subdomain (e.g., api.example.com).
114
+ """
99
115
 
100
116
 
101
117
  @dataclass
@@ -118,21 +134,55 @@ class ConnectionStatusConfig:
118
134
 
119
135
 
120
136
  class App:
121
- """
122
- Pulse UI Application - the main entry point for defining your app.
137
+ """Main Pulse application class.
123
138
 
139
+ Creates a server that handles routing, sessions, and WebSocket connections.
124
140
  Similar to FastAPI, users create an App instance and define their routes.
125
141
 
126
- Example:
127
- ```python
128
- import pulse as ps
142
+ Args:
143
+ routes: Route definitions for the application.
144
+ codegen: Code generation settings for React Router output.
145
+ middleware: Request middleware, either a single middleware or sequence.
146
+ plugins: Application plugins that can contribute routes, middleware,
147
+ and lifecycle hooks.
148
+ cookie: Session cookie configuration.
149
+ session_store: Session storage backend. Defaults to CookieSessionStore.
150
+ server_address: Public server URL. Required in production.
151
+ dev_server_address: Development server URL. Defaults to
152
+ "http://localhost:8000".
153
+ internal_server_address: Internal URL for server-side loader fetches.
154
+ Falls back to server_address if not provided.
155
+ not_found: Path for 404 page. Defaults to "/not-found".
156
+ mode: Deployment mode - "single-server" (default) or "subdomains".
157
+ api_prefix: API route prefix. Defaults to "/_pulse".
158
+ cors: CORS configuration. Auto-configured based on mode if not provided.
159
+ fastapi: Additional FastAPI constructor options.
160
+ session_timeout: Session cleanup timeout in seconds. Defaults to 60.0.
161
+ connection_status: Connection status UI timing configuration.
129
162
 
130
- app = ps.App()
163
+ Attributes:
164
+ env: Current environment ("dev", "ci", or "prod").
165
+ mode: Deployment mode ("single-server" or "subdomains").
166
+ status: Current application lifecycle status.
167
+ routes: Parsed route tree containing all registered routes.
168
+ fastapi: Underlying FastAPI instance.
169
+ asgi: ASGI application (includes Socket.IO).
131
170
 
132
- @app.route("/")
133
- def home():
134
- return ps.div("Hello World!")
135
- ```
171
+ Example:
172
+ ```python
173
+ import pulse as ps
174
+
175
+ app = ps.App(
176
+ routes=[
177
+ ps.Route("/", render=home),
178
+ ps.Route("/users/:id", render=user_detail),
179
+ ],
180
+ session_timeout=120.0,
181
+ )
182
+
183
+ if __name__ == "__main__":
184
+ app.run(port=8000)
185
+ ```
136
186
  """
137
187
 
138
188
  env: PulseEnv
@@ -162,6 +212,9 @@ class App:
162
212
  _render_cleanups: dict[str, asyncio.TimerHandle]
163
213
  session_timeout: float
164
214
  connection_status: ConnectionStatusConfig
215
+ render_loop_limit: int
216
+ detach_queue_timeout: float
217
+ disconnect_queue_timeout: float
165
218
 
166
219
  def __init__(
167
220
  self,
@@ -181,7 +234,10 @@ class App:
181
234
  cors: CORSOptions | None = None,
182
235
  fastapi: dict[str, Any] | None = None,
183
236
  session_timeout: float = 60.0,
237
+ detach_queue_timeout: float = 15.0,
238
+ disconnect_queue_timeout: float = 300.0,
184
239
  connection_status: ConnectionStatusConfig | None = None,
240
+ render_loop_limit: int = 50,
185
241
  ):
186
242
  # Resolve mode from environment and expose on the app instance
187
243
  self.env = envvars.pulse_env
@@ -228,7 +284,10 @@ class App:
228
284
  # Map render_id -> cleanup timer handle for timeout-based expiry
229
285
  self._render_cleanups = {}
230
286
  self.session_timeout = session_timeout
287
+ self.detach_queue_timeout = detach_queue_timeout
288
+ self.disconnect_queue_timeout = disconnect_queue_timeout
231
289
  self.connection_status = connection_status or ConnectionStatusConfig()
290
+ self.render_loop_limit = render_loop_limit
232
291
 
233
292
  self.codegen = Codegen(
234
293
  self.routes,
@@ -290,7 +349,21 @@ class App:
290
349
 
291
350
  def run_codegen(
292
351
  self, address: str | None = None, internal_address: str | None = None
293
- ):
352
+ ) -> None:
353
+ """Generate React Router code for all routes.
354
+
355
+ Generates TypeScript/JSX files for React Router integration based on
356
+ the application's route definitions.
357
+
358
+ Args:
359
+ address: Public server address. Updates server_address if provided.
360
+ internal_address: Internal server address for SSR fetches. Updates
361
+ internal_server_address if provided.
362
+
363
+ Raises:
364
+ RuntimeError: If no server address is available (neither passed
365
+ as argument nor set on the App instance).
366
+ """
294
367
  # Allow the CLI to disable codegen in specific scenarios (e.g., prod server-only)
295
368
  if envvars.codegen_disabled:
296
369
  return
@@ -309,11 +382,18 @@ class App:
309
382
  connection_status=self.connection_status,
310
383
  )
311
384
 
312
- def asgi_factory(self):
313
- """
314
- ASGI factory for uvicorn. This is called on every reload.
315
- """
385
+ def asgi_factory(self) -> ASGIApp:
386
+ """ASGI factory for production deployment.
387
+
388
+ Called on each uvicorn reload. Initializes code generation and sets up
389
+ the application with the appropriate server address.
316
390
 
391
+ Returns:
392
+ The ASGI application instance (includes Socket.IO).
393
+
394
+ Raises:
395
+ RuntimeError: If in prod/ci mode without an explicit server_address.
396
+ """
317
397
  # In prod/ci, use the server_address provided to App(...).
318
398
  if self.env in ("prod", "ci"):
319
399
  if not self.server_address:
@@ -347,13 +427,34 @@ class App:
347
427
  port: int = 8000,
348
428
  find_port: bool = True,
349
429
  reload: bool = True,
350
- ):
430
+ ) -> None:
431
+ """Start the development server with uvicorn.
432
+
433
+ Args:
434
+ address: Host address to bind to. Defaults to "localhost".
435
+ port: Port number to listen on. Defaults to 8000.
436
+ find_port: If True, automatically find an available port if the
437
+ specified port is in use. Defaults to True.
438
+ reload: If True, enable auto-reload on file changes. Defaults to True.
439
+ """
351
440
  if find_port:
352
441
  port = find_available_port(port)
353
442
 
354
443
  uvicorn.run(self.asgi_factory, reload=reload)
355
444
 
356
- def setup(self, server_address: str):
445
+ def setup(self, server_address: str) -> None:
446
+ """Initialize the app with a server address.
447
+
448
+ Configures FastAPI routes, middleware, CORS, and Socket.IO handlers.
449
+ Called automatically by asgi_factory().
450
+
451
+ Args:
452
+ server_address: The public URL where the server is accessible.
453
+
454
+ Note:
455
+ This method is idempotent - calling it multiple times on an already
456
+ initialized app will log a warning and return early.
457
+ """
357
458
  if self.status >= AppStatus.initialized:
358
459
  logger.warning("Called App.setup() on an already initialized application")
359
460
  return
@@ -436,6 +537,8 @@ class App:
436
537
  raise HTTPException(
437
538
  status_code=400, detail="'paths' must be a non-empty list"
438
539
  )
540
+ paths = [ensure_absolute_path(path) for path in paths]
541
+ payload["paths"] = paths
439
542
  route_info = payload.get("routeInfo")
440
543
 
441
544
  client_addr: str | None = get_client_address(request)
@@ -453,8 +556,9 @@ class App:
453
556
  # Schedule cleanup timeout (will cancel/reschedule on activity)
454
557
  self._schedule_render_cleanup(render_id)
455
558
 
456
- async def _prerender_one(path: str):
457
- captured = render.prerender(path, route_info)
559
+ def _normalize_prerender_result(
560
+ captured: ServerInitMessage | ServerNavigateToMessage,
561
+ ) -> Ok[ServerInitMessage] | Redirect | NotFound:
458
562
  if captured["type"] == "vdom_init":
459
563
  return Ok(captured)
460
564
  if captured["type"] == "navigate_to":
@@ -467,12 +571,6 @@ class App:
467
571
  # Fallback: shouldn't happen, return not found to be safe
468
572
  return NotFound()
469
573
 
470
- def _normalize_prerender_response(res: Any) -> RoutePrerenderResponse:
471
- if isinstance(res, (Ok, Redirect, NotFound)):
472
- return res
473
- # Treat any other value as a VDOM payload
474
- return Ok(res)
475
-
476
574
  with PulseContext.update(render=render):
477
575
  # Call top-level prerender middleware, which wraps the route processing
478
576
  async def _process_routes() -> PrerenderResponse:
@@ -487,37 +585,21 @@ class App:
487
585
  },
488
586
  }
489
587
 
490
- # Fan out on routes
588
+ captured = render.prerender(paths, route_info)
589
+
491
590
  for p in paths:
492
- try:
493
- # Capture p in closure to avoid loop variable binding issue
494
- async def _next(path: str = p) -> RoutePrerenderResponse:
495
- return await _prerender_one(path)
496
-
497
- # Call prerender_route middleware (in) -> prerender route -> (out)
498
- res = await self.middleware.prerender_route(
499
- path=p,
500
- route_info=route_info,
501
- request=PulseRequest.from_fastapi(request),
502
- session=session.data,
503
- next=_next,
504
- )
505
- res = _normalize_prerender_response(res)
506
- if isinstance(res, Ok):
507
- # Aggregate results
508
- result_data["views"][p] = res.payload
509
- elif isinstance(res, Redirect):
510
- # Return redirect immediately
511
- return Redirect(path=res.path or "/")
512
- elif isinstance(res, NotFound):
513
- # Return not found immediately
514
- return NotFound()
515
- else:
516
- raise ValueError("Unexpected prerender response:", res)
517
- except RedirectInterrupt as r:
518
- return Redirect(path=r.path)
519
- except NotFoundInterrupt:
591
+ res = _normalize_prerender_result(captured[p])
592
+ if isinstance(res, Ok):
593
+ # Aggregate results
594
+ result_data["views"][p] = res.payload
595
+ elif isinstance(res, Redirect):
596
+ # Return redirect immediately
597
+ return Redirect(path=res.path or "/")
598
+ elif isinstance(res, NotFound):
599
+ # Return not found immediately
520
600
  return NotFound()
601
+ else:
602
+ raise ValueError("Unexpected prerender response:", res)
521
603
 
522
604
  return Ok(result_data)
523
605
 
@@ -741,6 +823,8 @@ class App:
741
823
  async def _handle_pulse_message(
742
824
  self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
743
825
  ) -> None:
826
+ print(f"[MSG] {msg}")
827
+
744
828
  async def _next() -> Ok[None]:
745
829
  if msg["type"] == "attach":
746
830
  render.attach(msg["path"], msg["routeInfo"])
@@ -919,6 +1003,9 @@ class App:
919
1003
  self.routes,
920
1004
  server_address=self.server_address,
921
1005
  client_address=client_address,
1006
+ detach_queue_timeout=self.detach_queue_timeout,
1007
+ disconnect_queue_timeout=self.disconnect_queue_timeout,
1008
+ render_loop_limit=self.render_loop_limit,
922
1009
  )
923
1010
  self.render_sessions[rid] = render
924
1011
  self._render_to_user[rid] = session.sid
@@ -24,14 +24,40 @@ logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
26
  ChannelHandler = Callable[[Any], Any | Awaitable[Any]]
27
+ """Handler function for channel events. Can be sync or async.
28
+
29
+ Type alias for ``Callable[[Any], Any | Awaitable[Any]]``.
30
+ """
27
31
 
28
32
 
29
33
  class ChannelClosed(RuntimeError):
30
- """Raised when interacting with a channel that has been closed."""
34
+ """Raised when interacting with a channel that has been closed.
35
+
36
+ This exception is raised when attempting to call ``on()``, ``emit()``,
37
+ or ``request()`` on a channel that has already been closed.
38
+
39
+ Example:
40
+
41
+ ```python
42
+ ch = ps.channel("my-channel")
43
+ ch.close()
44
+ ch.emit("event") # Raises ChannelClosed
45
+ ```
46
+ """
31
47
 
32
48
 
33
49
  class ChannelTimeout(asyncio.TimeoutError):
34
- """Raised when a channel request times out waiting for a response."""
50
+ """Raised when a channel request times out waiting for a response.
51
+
52
+ This exception is raised by ``Channel.request()`` when the specified
53
+ timeout elapses before receiving a response from the client.
54
+
55
+ Example:
56
+
57
+ ```python
58
+ result = await ch.request("get_value", timeout=5.0) # Raises if no response in 5s
59
+ ```
60
+ """
35
61
 
36
62
 
37
63
  @dataclass(slots=True)
@@ -311,7 +337,32 @@ class ChannelsManager:
311
337
 
312
338
 
313
339
  class Channel:
314
- """Bidirectional communication channel bound to a render session."""
340
+ """Bidirectional communication channel bound to a render session.
341
+
342
+ Channels enable real-time messaging between server and client. Use
343
+ ``ps.channel()`` to create a channel within a component.
344
+
345
+ Attributes:
346
+ id: Channel identifier (auto-generated UUID or user-provided).
347
+ render_id: Associated render session ID.
348
+ session_id: Associated user session ID.
349
+ route_path: Route path this channel is bound to, or None.
350
+ closed: Whether the channel has been closed.
351
+
352
+ Example:
353
+
354
+ ```python
355
+ @ps.component
356
+ def ChatRoom():
357
+ ch = ps.channel("chat")
358
+
359
+ @ch.on("message")
360
+ def handle_message(payload):
361
+ ch.emit("broadcast", payload)
362
+
363
+ return ps.div("Chat room")
364
+ ```
365
+ """
315
366
 
316
367
  _manager: ChannelsManager
317
368
  id: str
@@ -344,7 +395,24 @@ class Channel:
344
395
  def on(self, event: str, handler: ChannelHandler) -> Callable[[], None]:
345
396
  """Register a handler for an incoming event.
346
397
 
347
- Returns a callable that removes the handler when invoked.
398
+ Args:
399
+ event: Event name to listen for.
400
+ handler: Callback function ``(payload: Any) -> Any | Awaitable[Any]``.
401
+
402
+ Returns:
403
+ Callable that removes the handler when invoked.
404
+
405
+ Raises:
406
+ ChannelClosed: If the channel is closed.
407
+
408
+ Example:
409
+
410
+ ```python
411
+ ch = ps.channel()
412
+ remove_handler = ch.on("data", lambda payload: print(payload))
413
+ # Later, to unregister:
414
+ remove_handler()
415
+ ```
348
416
  """
349
417
 
350
418
  self._ensure_open()
@@ -368,7 +436,21 @@ class Channel:
368
436
  # Outgoing messages
369
437
  # ---------------------------------------------------------------------
370
438
  def emit(self, event: str, payload: Any = None) -> None:
371
- """Send a fire-and-forget event to the client."""
439
+ """Send a fire-and-forget event to the client.
440
+
441
+ Args:
442
+ event: Event name.
443
+ payload: Data to send (optional).
444
+
445
+ Raises:
446
+ ChannelClosed: If the channel is closed.
447
+
448
+ Example:
449
+
450
+ ```python
451
+ ch.emit("notification", {"message": "Hello"})
452
+ ```
453
+ """
372
454
 
373
455
  self._ensure_open()
374
456
  msg = ServerChannelRequestMessage(
@@ -389,7 +471,26 @@ class Channel:
389
471
  *,
390
472
  timeout: float | None = None,
391
473
  ) -> Any:
392
- """Send a request to the client and await the response."""
474
+ """Send a request to the client and await the response.
475
+
476
+ Args:
477
+ event: Event name.
478
+ payload: Data to send (optional).
479
+ timeout: Timeout in seconds (optional).
480
+
481
+ Returns:
482
+ Response payload from client.
483
+
484
+ Raises:
485
+ ChannelClosed: If the channel is closed.
486
+ ChannelTimeout: If the request times out.
487
+
488
+ Example:
489
+
490
+ ```python
491
+ result = await ch.request("get_value", timeout=5.0)
492
+ ```
493
+ """
393
494
 
394
495
  self._ensure_open()
395
496
  request_id = uuid.uuid4().hex
@@ -421,6 +522,11 @@ class Channel:
421
522
 
422
523
  # ---------------------------------------------------------------------
423
524
  def close(self) -> None:
525
+ """Close the channel and clean up resources.
526
+
527
+ After closing, any further operations on the channel will raise
528
+ ``ChannelClosed``. Pending requests will be cancelled.
529
+ """
424
530
  if self.closed:
425
531
  return
426
532
  self.closed = True
@@ -458,7 +564,33 @@ class Channel:
458
564
 
459
565
 
460
566
  def channel(identifier: str | None = None) -> Channel:
461
- """Convenience helper to create a channel using the active PulseContext."""
567
+ """Create a channel bound to the current render session.
568
+
569
+ Args:
570
+ identifier: Optional channel ID. Auto-generated UUID if not provided.
571
+
572
+ Returns:
573
+ Channel instance.
574
+
575
+ Raises:
576
+ RuntimeError: If called outside an active render session.
577
+
578
+ Example:
579
+
580
+ ```python
581
+ import pulse as ps
582
+
583
+ @ps.component
584
+ def ChatRoom():
585
+ ch = ps.channel("chat")
586
+
587
+ @ch.on("message")
588
+ def handle_message(payload):
589
+ ch.emit("broadcast", payload)
590
+
591
+ return ps.div("Chat room")
592
+ ```
593
+ """
462
594
 
463
595
  ctx = PulseContext.get()
464
596
  if ctx.render is None:
@@ -210,6 +210,7 @@ def run(
210
210
  mode=app_instance.env,
211
211
  ready_pattern=r"localhost:\d+",
212
212
  on_ready=mark_web_ready,
213
+ plain=plain,
213
214
  )
214
215
  commands.append(web_cmd)
215
216
  # Set env var so app can read the React server address (only used in single-server mode)
@@ -228,6 +229,7 @@ def run(
228
229
  verbose=verbose,
229
230
  ready_pattern=r"Application startup complete",
230
231
  on_ready=mark_server_ready,
232
+ plain=plain,
231
233
  )
232
234
  commands.append(server_cmd)
233
235
 
@@ -394,6 +396,7 @@ def build_uvicorn_command(
394
396
  verbose: bool = False,
395
397
  ready_pattern: str | None = None,
396
398
  on_ready: Callable[[], None] | None = None,
399
+ plain: bool = False,
397
400
  ) -> CommandSpec:
398
401
  app_import = f"{app_ctx.module_name}:{app_ctx.app_var}.asgi_factory"
399
402
  args: list[str] = [
@@ -421,16 +424,22 @@ def build_uvicorn_command(
421
424
 
422
425
  if extra_args:
423
426
  args.extend(extra_args)
427
+ if plain:
428
+ args.append("--no-use-colors")
424
429
 
425
430
  command_env = os.environ.copy()
426
431
  command_env.update(
427
432
  {
428
- "FORCE_COLOR": "1",
429
433
  "PYTHONUNBUFFERED": "1",
430
434
  ENV_PULSE_HOST: address,
431
435
  ENV_PULSE_PORT: str(port),
432
436
  }
433
437
  )
438
+ if plain:
439
+ command_env["NO_COLOR"] = "1"
440
+ command_env["FORCE_COLOR"] = "0"
441
+ else:
442
+ command_env["FORCE_COLOR"] = "1"
434
443
  # Pass React server address to uvicorn process if set
435
444
  if ENV_PULSE_REACT_SERVER_ADDRESS in os.environ:
436
445
  command_env[ENV_PULSE_REACT_SERVER_ADDRESS] = os.environ[
@@ -471,6 +480,7 @@ def build_web_command(
471
480
  mode: PulseEnv = "dev",
472
481
  ready_pattern: str | None = None,
473
482
  on_ready: Callable[[], None] | None = None,
483
+ plain: bool = False,
474
484
  ) -> CommandSpec:
475
485
  command_env = os.environ.copy()
476
486
  if mode == "prod":
@@ -493,10 +503,14 @@ def build_web_command(
493
503
 
494
504
  command_env.update(
495
505
  {
496
- "FORCE_COLOR": "1",
497
506
  "PYTHONUNBUFFERED": "1",
498
507
  }
499
508
  )
509
+ if plain:
510
+ command_env["NO_COLOR"] = "1"
511
+ command_env["FORCE_COLOR"] = "0"
512
+ else:
513
+ command_env["FORCE_COLOR"] = "1"
500
514
 
501
515
  return CommandSpec(
502
516
  name="web",