pulse-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. pulse/__init__.py +175 -0
  2. pulse/app.py +349 -0
  3. pulse/cmd.py +324 -0
  4. pulse/codegen.py +147 -0
  5. pulse/components/__init__.py +1 -0
  6. pulse/components/react_router.py +43 -0
  7. pulse/context.py +15 -0
  8. pulse/decorators.py +187 -0
  9. pulse/diff.py +252 -0
  10. pulse/flags.py +5 -0
  11. pulse/flatted.py +159 -0
  12. pulse/helpers.py +27 -0
  13. pulse/hooks.py +441 -0
  14. pulse/html/__init__.py +304 -0
  15. pulse/html/attributes.py +930 -0
  16. pulse/html/elements.py +1024 -0
  17. pulse/html/events.py +419 -0
  18. pulse/html/tags.py +171 -0
  19. pulse/html/tags.pyi +390 -0
  20. pulse/messages.py +109 -0
  21. pulse/middleware.py +158 -0
  22. pulse/query.py +286 -0
  23. pulse/react_component.py +803 -0
  24. pulse/reactive.py +514 -0
  25. pulse/reactive_extensions.py +626 -0
  26. pulse/reconciler.py +575 -0
  27. pulse/request.py +162 -0
  28. pulse/routing.py +350 -0
  29. pulse/session.py +310 -0
  30. pulse/state.py +309 -0
  31. pulse/templates.py +171 -0
  32. pulse/tests/__init__.py +0 -0
  33. pulse/tests/old_test_diff.py +174 -0
  34. pulse/tests/test_codegen.py +224 -0
  35. pulse/tests/test_flatted.py +297 -0
  36. pulse/tests/test_nodes.py +439 -0
  37. pulse/tests/test_query.py +391 -0
  38. pulse/tests/test_react.py +797 -0
  39. pulse/tests/test_reactive.py +1203 -0
  40. pulse/tests/test_reconciler.py +1759 -0
  41. pulse/tests/test_routing.py +167 -0
  42. pulse/tests/test_session.py +267 -0
  43. pulse/tests/test_state.py +569 -0
  44. pulse/tests/test_utils.py +101 -0
  45. pulse/vdom.py +381 -0
  46. pulse_framework-0.1.0.dist-info/METADATA +38 -0
  47. pulse_framework-0.1.0.dist-info/RECORD +50 -0
  48. pulse_framework-0.1.0.dist-info/WHEEL +4 -0
  49. pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
  50. pulse_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
pulse/__init__.py ADDED
@@ -0,0 +1,175 @@
1
+ from .app import App, Session
2
+ from .state import State
3
+ from .routing import Route, Layout
4
+ from .reactive import Signal, Computed, Effect, Batch, Untrack, IgnoreBatch
5
+ from .reactive_extensions import ReactiveDict, ReactiveList, ReactiveSet, reactive
6
+ from .hooks import (
7
+ states,
8
+ effects,
9
+ setup,
10
+ route_info,
11
+ session_context,
12
+ call_api,
13
+ navigate,
14
+ )
15
+ from .hooks import global_state
16
+ from .html import * # noqa: F403
17
+ from .middleware import (
18
+ PulseMiddleware,
19
+ Ok,
20
+ Redirect,
21
+ NotFound,
22
+ Deny,
23
+ PulseRequest,
24
+ ConnectResponse,
25
+ PrerenderResponse,
26
+ MiddlewareStack,
27
+ stack,
28
+ )
29
+ from .decorators import computed, effect, query
30
+
31
+ # Import HTML tags and other UI components
32
+ from .vdom import (
33
+ Node,
34
+ Element,
35
+ Primitive,
36
+ VDOMNode,
37
+ component,
38
+ Component,
39
+ ComponentNode,
40
+ Child,
41
+ )
42
+ from .html.tags import (
43
+ # Standard HTML tags
44
+ a,
45
+ abbr,
46
+ address,
47
+ article,
48
+ aside,
49
+ audio,
50
+ b,
51
+ bdi,
52
+ bdo,
53
+ blockquote,
54
+ body,
55
+ button,
56
+ canvas,
57
+ caption,
58
+ cite,
59
+ code,
60
+ colgroup,
61
+ data,
62
+ datalist,
63
+ dd,
64
+ del_,
65
+ details,
66
+ dfn,
67
+ dialog,
68
+ div,
69
+ dl,
70
+ dt,
71
+ em,
72
+ fieldset,
73
+ figcaption,
74
+ figure,
75
+ footer,
76
+ form,
77
+ h1,
78
+ h2,
79
+ h3,
80
+ h4,
81
+ h5,
82
+ h6,
83
+ head,
84
+ header,
85
+ hgroup,
86
+ html,
87
+ i,
88
+ iframe,
89
+ ins,
90
+ kbd,
91
+ label,
92
+ legend,
93
+ li,
94
+ main,
95
+ map_,
96
+ mark,
97
+ menu,
98
+ meter,
99
+ nav,
100
+ noscript,
101
+ object_,
102
+ ol,
103
+ optgroup,
104
+ option,
105
+ output,
106
+ p,
107
+ picture,
108
+ pre,
109
+ progress,
110
+ q,
111
+ rp,
112
+ rt,
113
+ ruby,
114
+ s,
115
+ samp,
116
+ script,
117
+ section,
118
+ select,
119
+ small,
120
+ span,
121
+ strong,
122
+ style,
123
+ sub,
124
+ summary,
125
+ sup,
126
+ table,
127
+ tbody,
128
+ td,
129
+ template,
130
+ textarea,
131
+ tfoot,
132
+ th,
133
+ thead,
134
+ time,
135
+ title,
136
+ tr,
137
+ u,
138
+ ul,
139
+ var,
140
+ video,
141
+ # Self-closing tags
142
+ area,
143
+ base,
144
+ br,
145
+ col,
146
+ embed,
147
+ hr,
148
+ img,
149
+ input,
150
+ link,
151
+ meta,
152
+ param,
153
+ source,
154
+ track,
155
+ wbr,
156
+ # React fragment
157
+ fragment,
158
+ )
159
+
160
+ from .codegen import CodegenConfig
161
+ from .components import (
162
+ Link,
163
+ Outlet,
164
+ )
165
+ from .react_component import (
166
+ ComponentRegistry,
167
+ COMPONENT_REGISTRY,
168
+ ReactComponent,
169
+ react_component,
170
+ registered_react_components,
171
+ Prop,
172
+ prop,
173
+ DEFAULT,
174
+ )
175
+ from .helpers import EventHandler, For
pulse/app.py ADDED
@@ -0,0 +1,349 @@
1
+ """
2
+ Pulse UI App class - similar to FastAPI's App.
3
+
4
+ This module provides the main App class that users instantiate in their main.py
5
+ to define routes and configure their Pulse application.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ from enum import IntEnum
12
+ from typing import Optional, Sequence, TypedDict, TypeVar, Unpack
13
+ from uuid import uuid4
14
+
15
+ import socketio
16
+ from fastapi import FastAPI, HTTPException, Request
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+
19
+ from pulse.codegen import Codegen, CodegenConfig
20
+ from pulse.react_component import ReactComponent, registered_react_components
21
+ from pulse.messages import ClientMessage, RouteInfo, ServerMessage
22
+ from pulse import flatted
23
+ from pulse.middleware import (
24
+ Deny,
25
+ MiddlewareStack,
26
+ NotFound,
27
+ Ok,
28
+ PulseMiddleware,
29
+ Redirect,
30
+ )
31
+ from pulse.reactive import (
32
+ REACTIVE_CONTEXT,
33
+ Epoch,
34
+ GlobalBatch,
35
+ ReactiveContext,
36
+ Scope,
37
+ )
38
+ from pulse.request import PulseRequest
39
+ from pulse.routing import Layout, Route, RouteTree
40
+ from pulse.session import Session
41
+ from pulse.vdom import VDOM
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ T = TypeVar("T")
46
+
47
+
48
+ class AppStatus(IntEnum):
49
+ created = 0
50
+ initialized = 1
51
+ running = 2
52
+ stopped = 3
53
+
54
+
55
+ class AppConfig(TypedDict, total=False):
56
+ server_address: str
57
+
58
+
59
+ class App:
60
+ """
61
+ Pulse UI Application - the main entry point for defining your app.
62
+
63
+ Similar to FastAPI, users create an App instance and define their routes.
64
+
65
+ Example:
66
+ ```python
67
+ import pulse as ps
68
+
69
+ app = ps.App()
70
+
71
+ @app.route("/")
72
+ def home():
73
+ return ps.div("Hello World!")
74
+ ```
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ routes: Optional[Sequence[Route | Layout]] = None,
80
+ codegen: Optional[CodegenConfig] = None,
81
+ middleware: Optional[PulseMiddleware | Sequence[PulseMiddleware]] = None,
82
+ **config: Unpack[AppConfig],
83
+ ):
84
+ """
85
+ Initialize a new Pulse App.
86
+
87
+ Args:
88
+ routes: Optional list of Route objects to register.
89
+ codegen: Optional codegen configuration.
90
+ """
91
+ self.config = config
92
+
93
+ routes = routes or []
94
+ # Auto-add React components to all routes
95
+ add_react_components(routes, registered_react_components())
96
+ self.routes = RouteTree(routes)
97
+ self.sessions: dict[str, Session] = {}
98
+
99
+ self.codegen = Codegen(
100
+ self.routes,
101
+ config=codegen or CodegenConfig(),
102
+ )
103
+
104
+ self.fastapi = FastAPI(title="Pulse UI Server")
105
+ self.sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
106
+ self.asgi = socketio.ASGIApp(self.sio, self.fastapi)
107
+ self.status = AppStatus.created
108
+ # Allow single middleware or sequence; compose into a stack when needed
109
+ if middleware is None:
110
+ self._middleware: PulseMiddleware | None = None
111
+ elif isinstance(middleware, PulseMiddleware):
112
+ self._middleware = middleware
113
+ else:
114
+ self._middleware = MiddlewareStack(middleware)
115
+
116
+ def setup(self):
117
+ if self.status >= AppStatus.initialized:
118
+ logger.warning("Called App.setup() on an already initialized application")
119
+ return
120
+
121
+ # Add CORS middleware
122
+ REACTIVE_CONTEXT.set(AppReactiveContext())
123
+ self.fastapi.add_middleware(
124
+ CORSMiddleware,
125
+ allow_origin_regex=".*",
126
+ allow_credentials=True,
127
+ allow_methods=["*"],
128
+ allow_headers=["*"],
129
+ )
130
+
131
+ @self.fastapi.get("/health")
132
+ def healthcheck():
133
+ return {"health": "ok", "message": "Pulse server is running"}
134
+
135
+ # RouteInfo is the request body
136
+ @self.fastapi.post("/prerender/{path:path}")
137
+ def prerender(path: str, route_info: RouteInfo, request: Request) -> VDOM:
138
+ # Provide a working reactive context (and not the global AppReactiveContext which errors)
139
+ if not path.startswith("/"):
140
+ path = "/" + path
141
+ session = Session(uuid4().hex, self.routes)
142
+
143
+ def _render() -> VDOM:
144
+ return session.render(path, route_info, prerendering=True)
145
+
146
+ if not self._middleware:
147
+ return _render()
148
+ try:
149
+
150
+ def _next():
151
+ return Ok(_render())
152
+
153
+ with session.reactive_context:
154
+ res = self._middleware.prerender(
155
+ path=path,
156
+ route_info=route_info,
157
+ request=PulseRequest.from_fastapi(request),
158
+ context=session.context,
159
+ next=_next,
160
+ )
161
+ except Exception:
162
+ logger.exception("Error in prerender middleware")
163
+ res = Ok(_render())
164
+ if isinstance(res, Redirect):
165
+ raise HTTPException(
166
+ status_code=302, headers={"Location": res.path or "/"}
167
+ )
168
+ elif isinstance(res, NotFound):
169
+ raise HTTPException(status_code=404)
170
+ elif isinstance(res, Ok):
171
+ return res.payload
172
+ # Fallback to default render
173
+ else:
174
+ raise NotImplementedError(f"Unexpected middleware return: {res}")
175
+
176
+ @self.sio.event
177
+ async def connect(sid: str, environ, auth=None):
178
+ # Create session first to instantiate reactive and session contexts
179
+ session = self.create_session(sid)
180
+ if self._middleware:
181
+ try:
182
+
183
+ def _next():
184
+ return Ok(None)
185
+
186
+ # Ensure middleware executes within the session's reactive context
187
+ with session.reactive_context:
188
+ res = self._middleware.connect(
189
+ request=PulseRequest.from_socketio_environ(environ, auth),
190
+ ctx=session.context,
191
+ next=_next,
192
+ )
193
+ except Exception:
194
+ logger.exception("Error in connect middleware")
195
+ res = Ok(None)
196
+ if isinstance(res, Deny):
197
+ # Tear down the created session if denied
198
+ try:
199
+ self.close_session(sid)
200
+ finally:
201
+ return False
202
+
203
+ def on_message(message: ServerMessage):
204
+ message = flatted.stringify(message)
205
+ asyncio.create_task(self.sio.emit("message", message, to=sid))
206
+
207
+ session.connect(on_message)
208
+
209
+ @self.sio.event
210
+ def disconnect(sid: str):
211
+ self.close_session(sid)
212
+
213
+ @self.sio.event
214
+ def message(sid: str, data: ClientMessage):
215
+ try:
216
+ # Deserialize the message using flatted
217
+ data = flatted.parse(data)
218
+ session = self.sessions[sid]
219
+
220
+ def _handler(sess: Session) -> None:
221
+ # Per-message middleware guard
222
+ if self._middleware:
223
+ try:
224
+ # Run middleware within the session's reactive context
225
+ with sess.reactive_context:
226
+ res = self._middleware.message(
227
+ ctx=sess.context,
228
+ data=data,
229
+ next=lambda: Ok(None),
230
+ )
231
+ if isinstance(res, Deny):
232
+ # Report as server error for this path
233
+ path = data.get("path")
234
+ sess.report_error(
235
+ path or "api_response",
236
+ "server",
237
+ Exception("Request denied by server"),
238
+ {"kind": "deny"},
239
+ )
240
+ return
241
+ except Exception:
242
+ logger.exception("Error in message middleware")
243
+ if data["type"] == "mount":
244
+ sess.mount(data["path"], data["routeInfo"])
245
+ elif data["type"] == "navigate":
246
+ sess.navigate(data["path"], data["routeInfo"])
247
+ elif data["type"] == "callback":
248
+ sess.execute_callback(
249
+ data["path"], data["callback"], data["args"]
250
+ )
251
+ elif data["type"] == "unmount":
252
+ sess.unmount(data["path"])
253
+ elif data["type"] == "api_result":
254
+ # type: ignore[union-attr]
255
+ sess.handle_api_result(data) # type: ignore[arg-type]
256
+ else:
257
+ logger.warning(f"Unknown message type received: {data}")
258
+
259
+ _handler(session)
260
+ except Exception as e:
261
+ try:
262
+ # Best effort: report error for this path if available
263
+ path = data.get("path", "") if isinstance(data, dict) else ""
264
+ session = self.sessions.get(sid)
265
+ if session:
266
+ session.report_error(path, "server", e)
267
+ else:
268
+ logger.exception("Error handling client message: %s", data)
269
+ except Exception as e:
270
+ logger.exception("Error while reporting server error: %s", e)
271
+
272
+ def run_codegen(self, address: Optional[str] = None):
273
+ address = address or self.config.get("server_address")
274
+ if not address:
275
+ raise RuntimeError(
276
+ "Please provide a server address to the App constructor or the Pulse CLI."
277
+ )
278
+ self.codegen.generate_all(address)
279
+
280
+ def asgi_factory(self):
281
+ """
282
+ ASGI factory for uvicorn. This is called on every reload.
283
+ """
284
+
285
+ host = os.environ.get("PULSE_HOST", "127.0.0.1")
286
+ port = int(os.environ.get("PULSE_PORT", 8000))
287
+ protocol = "http" if host in ("127.0.0.1", "localhost") else "https"
288
+
289
+ self.run_codegen(f"{protocol}://{host}:{port}")
290
+ self.setup()
291
+ return self.asgi
292
+
293
+ def get_route(self, path: str):
294
+ self.routes.find(path)
295
+
296
+ def create_session(self, id: str):
297
+ if id in self.sessions:
298
+ raise ValueError(f"Session {id} already exists")
299
+ # print(f"--> Creating session {id}")
300
+ self.sessions[id] = Session(id, self.routes)
301
+ return self.sessions[id]
302
+
303
+ def close_session(self, id: str):
304
+ if id not in self.sessions:
305
+ raise KeyError(f"Session {id} does not exist")
306
+ self.sessions[id].close()
307
+ del self.sessions[id]
308
+
309
+
310
+ def add_react_components(
311
+ routes: Sequence[Route | Layout], components: list[ReactComponent]
312
+ ):
313
+ for route in routes:
314
+ if route.components is None:
315
+ route.components = components
316
+ if route.children:
317
+ add_react_components(route.children, components)
318
+
319
+
320
+ class AppReactiveContext(ReactiveContext):
321
+ def __init__(self, allow_usage=False) -> None:
322
+ self._epoch = Epoch()
323
+ self._batch = GlobalBatch()
324
+ self._scope = Scope()
325
+ self.allow_usage = allow_usage
326
+
327
+ @property
328
+ def epoch(self):
329
+ if self.allow_usage:
330
+ return self._epoch
331
+ raise RuntimeError(
332
+ "App reactive context should not be used, all reactive context should be scoped to sessions."
333
+ )
334
+
335
+ @property
336
+ def batch(self):
337
+ if self.allow_usage:
338
+ return self._batch
339
+ raise RuntimeError(
340
+ "App reactive context should not be used, all reactive context should be scoped to sessions."
341
+ )
342
+
343
+ @property
344
+ def scope(self):
345
+ if self.allow_usage:
346
+ return self._scope
347
+ raise RuntimeError(
348
+ "App reactive context should not be used, all reactive context should be scoped to sessions."
349
+ )