pulse-framework 0.1.38a6__tar.gz → 0.1.38a7__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 (70) hide show
  1. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/app.py +93 -37
  4. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/layout.py +1 -3
  5. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/README.md +0 -0
  6. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/__init__.py +0 -0
  7. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/channel.py +0 -0
  8. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/__init__.py +0 -0
  9. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/cmd.py +0 -0
  10. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/dependencies.py +0 -0
  11. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/folder_lock.py +0 -0
  12. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/helpers.py +0 -0
  13. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/models.py +0 -0
  14. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/packages.py +0 -0
  15. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/processes.py +0 -0
  16. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/secrets.py +0 -0
  17. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cli/uvicorn_log_config.py +0 -0
  18. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/__init__.py +0 -0
  19. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/codegen.py +0 -0
  20. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/imports.py +0 -0
  21. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/js.py +0 -0
  22. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/__init__.py +0 -0
  23. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/route.py +0 -0
  24. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/routes_ts.py +0 -0
  25. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/codegen/utils.py +0 -0
  26. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/components/__init__.py +0 -0
  27. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/components/for_.py +0 -0
  28. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/components/if_.py +0 -0
  29. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/components/react_router.py +0 -0
  30. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/context.py +0 -0
  31. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/cookies.py +0 -0
  32. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/css.py +0 -0
  33. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/decorators.py +0 -0
  34. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/env.py +0 -0
  35. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/form.py +0 -0
  36. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/helpers.py +0 -0
  37. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/__init__.py +0 -0
  38. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/core.py +0 -0
  39. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/effects.py +0 -0
  40. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/runtime.py +0 -0
  41. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/setup.py +0 -0
  42. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/stable.py +0 -0
  43. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/hooks/states.py +0 -0
  44. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/__init__.py +0 -0
  45. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/elements.py +0 -0
  46. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/events.py +0 -0
  47. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/props.py +0 -0
  48. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/svg.py +0 -0
  49. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/tags.py +0 -0
  50. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/html/tags.pyi +0 -0
  51. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/messages.py +0 -0
  52. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/middleware.py +0 -0
  53. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/plugin.py +0 -0
  54. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/proxy.py +0 -0
  55. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/py.typed +0 -0
  56. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/query.py +0 -0
  57. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/react_component.py +0 -0
  58. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/reactive.py +0 -0
  59. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/reactive_extensions.py +0 -0
  60. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/render_session.py +0 -0
  61. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/renderer.py +0 -0
  62. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/request.py +0 -0
  63. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/routing.py +0 -0
  64. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/serializer.py +0 -0
  65. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/state.py +0 -0
  66. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/types/__init__.py +0 -0
  67. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/types/event_handler.py +0 -0
  68. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/user_session.py +0 -0
  69. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/src/pulse/vdom.py +0 -0
  70. {pulse_framework-0.1.38a6 → pulse_framework-0.1.38a7}/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.38a6
3
+ Version: 0.1.38a7
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.38a6"
3
+ version = "0.1.38a7"
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"
@@ -5,6 +5,7 @@ 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
8
9
  import logging
9
10
  import os
10
11
  from collections import defaultdict
@@ -141,6 +142,8 @@ class App:
141
142
  _render_to_user: dict[str, str]
142
143
  _sessions_in_request: dict[str, int]
143
144
  _socket_to_render: dict[str, str]
145
+ _render_cleanups: dict[str, asyncio.TimerHandle]
146
+ session_timeout: float
144
147
 
145
148
  def __init__(
146
149
  self,
@@ -160,6 +163,7 @@ class App:
160
163
  api_prefix: str = "/_pulse",
161
164
  cors: CORSOptions | None = None,
162
165
  fastapi: dict[str, Any] | None = None,
166
+ session_timeout: float = 60.0,
163
167
  ):
164
168
  """
165
169
  Initialize a new Pulse App.
@@ -215,6 +219,9 @@ class App:
215
219
  self._sessions_in_request = {}
216
220
  # Map websocket sid -> renderId for message routing
217
221
  self._socket_to_render = {}
222
+ # Map render_id -> cleanup timer handle for timeout-based expiry
223
+ self._render_cleanups = {}
224
+ self.session_timeout = session_timeout
218
225
 
219
226
  self.codegen = Codegen(
220
227
  self.routes,
@@ -367,7 +374,6 @@ class App:
367
374
  else:
368
375
  # Use deployment-specific CORS settings
369
376
  cors_config = cors_options(self.mode, self.server_address)
370
- print(f"CORS config: {cors_config}")
371
377
  self.fastapi.add_middleware(
372
378
  CORSMiddleware,
373
379
  **cors_config,
@@ -412,10 +418,9 @@ class App:
412
418
  self._sessions_in_request.get(session.sid, 0) + 1
413
419
  )
414
420
  render_id = request.headers.get("x-pulse-render-id")
415
- if render_id:
416
- render = self.render_sessions.get(render_id)
417
- else:
418
- render = None
421
+ render = self._get_render_for_session(render_id, session)
422
+ if render:
423
+ print(f"Reusing render session {render_id}")
419
424
  with PulseContext.update(session=session, render=render):
420
425
  res: Response = await call_next(request)
421
426
  session.handle_response(res)
@@ -442,7 +447,8 @@ class App:
442
447
  async def prerender(payload: PrerenderPayload, request: Request): # pyright: ignore[reportUnusedFunction]
443
448
  """
444
449
  POST /prerender
445
- Body: { paths: string[], routeInfo: RouteInfo, ttlSeconds?: number, renderId?: string }
450
+ Body: { paths: string[], routeInfo: RouteInfo, ttlSeconds?: number }
451
+ Headers: X-Pulse-Render-Id (optional, for render session reuse)
446
452
  Returns: { renderId: string, <path>: VDOM, ... }
447
453
  """
448
454
  session = PulseContext.get().session
@@ -454,29 +460,21 @@ class App:
454
460
  status_code=400, detail="'paths' must be a non-empty list"
455
461
  )
456
462
  route_info = payload.get("routeInfo")
457
- ttl = payload.get("ttlSeconds")
458
- if not isinstance(ttl, (int, float)):
459
- ttl = 15
460
463
 
461
464
  client_addr: str | None = get_client_address(request)
462
- # Optional reuse of existing RenderSession
463
- render_id = payload.get("renderId")
464
- if isinstance(render_id, str):
465
- # Validate render exists and belongs to this user session
466
- existing = self.render_sessions.get(render_id)
467
- if existing is None:
468
- raise HTTPException(status_code=400, detail="Unknown renderId")
469
- owner = self._render_to_user.get(render_id)
470
- if owner != session.sid:
471
- raise HTTPException(status_code=403, detail="Forbidden renderId")
472
- render = existing
473
- cleanup = False
465
+ # Reuse render session from header (set by middleware) or create new one
466
+ render = PulseContext.get().render
467
+ if render is not None:
468
+ render_id = render.id
474
469
  else:
470
+ # Create new render session
475
471
  render_id = new_sid()
476
472
  render = self.create_render(
477
473
  render_id, session, client_address=client_addr
478
474
  )
479
- cleanup = True
475
+
476
+ # Schedule cleanup timeout (will cancel/reschedule on activity)
477
+ self._schedule_render_cleanup(render_id)
480
478
 
481
479
  initial_result: PrerenderResult = {
482
480
  "views": {},
@@ -536,18 +534,6 @@ class App:
536
534
  session.handle_response(resp)
537
535
  return resp
538
536
 
539
- # schedule TTL cleanup if never connected
540
- def _gc_if_unadopted(rid: str):
541
- r = self.render_sessions.get(rid)
542
- if r is None:
543
- return
544
- if r.connected:
545
- return
546
- self.close_render(rid)
547
-
548
- if cleanup:
549
- later(float(ttl), _gc_if_unadopted, render_id)
550
-
551
537
  # Call top-level batch prerender middleware to modify final result
552
538
  def _return_result() -> PrerenderResult:
553
539
  return result
@@ -620,6 +606,9 @@ class App:
620
606
  # Map socket sid to renderId for message routing
621
607
  self._socket_to_render[sid] = rid
622
608
 
609
+ # Cancel any pending cleanup since session is now connected
610
+ self._cancel_render_cleanup(rid)
611
+
623
612
  with PulseContext.update(session=session, render=render):
624
613
 
625
614
  def _next():
@@ -635,6 +624,7 @@ class App:
635
624
  render.report_error("/", "connect", exc)
636
625
  res = Ok(None)
637
626
  if isinstance(res, Deny):
627
+ print(f"Denying connection, closing RenderSession {rid}")
638
628
  # Tear down the created session if denied
639
629
  self.close_render(rid)
640
630
 
@@ -642,8 +632,12 @@ class App:
642
632
  def disconnect(sid: str): # pyright: ignore[reportUnusedFunction]
643
633
  rid = self._socket_to_render.pop(sid, None)
644
634
  if rid is not None:
645
- # Close the RenderSession entirely to avoid lingering effects/tasks
646
- self.close_render(rid)
635
+ print(f"Disconnecting WebSocket for RenderSession {rid}")
636
+ render = self.render_sessions.get(rid)
637
+ if render:
638
+ render.connected = False
639
+ # Schedule cleanup after timeout (will keep session alive for reuse)
640
+ self._schedule_render_cleanup(rid)
647
641
 
648
642
  @self.sio.event
649
643
  def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
@@ -653,6 +647,8 @@ class App:
653
647
  render = self.render_sessions.get(rid)
654
648
  if render is None:
655
649
  return
650
+ # Cancel any pending cleanup for active sessions (connected sessions stay alive)
651
+ self._cancel_render_cleanup(rid)
656
652
  # Use renderId mapping to user session
657
653
  session = self.user_sessions[self._render_to_user[rid]]
658
654
  # Make sure to properly deserialize the message contents
@@ -668,6 +664,39 @@ class App:
668
664
 
669
665
  self.status = AppStatus.initialized
670
666
 
667
+ def _cancel_render_cleanup(self, rid: str):
668
+ """Cancel any pending cleanup task for a render session."""
669
+ cleanup_handle = self._render_cleanups.pop(rid, None)
670
+ if cleanup_handle and not cleanup_handle.cancelled():
671
+ cleanup_handle.cancel()
672
+
673
+ def _schedule_render_cleanup(self, rid: str):
674
+ """Schedule cleanup of a RenderSession after the configured timeout."""
675
+ render = self.render_sessions.get(rid)
676
+ if render is None:
677
+ return
678
+ # Don't schedule cleanup for connected sessions (they stay alive)
679
+ if render.connected:
680
+ return
681
+
682
+ # Cancel any existing cleanup task for this render session
683
+ self._cancel_render_cleanup(rid)
684
+
685
+ # Schedule new cleanup task
686
+ def _cleanup():
687
+ render = self.render_sessions.get(rid)
688
+ if render is None:
689
+ return
690
+ # Only cleanup if not connected (if connected, keep it alive)
691
+ if not render.connected:
692
+ logger.info(
693
+ f"RenderSession {rid} expired after {self.session_timeout}s timeout"
694
+ )
695
+ self.close_render(rid)
696
+
697
+ handle = later(self.session_timeout, _cleanup)
698
+ self._render_cleanups[rid] = handle
699
+
671
700
  def _handle_pulse_message(
672
701
  self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
673
702
  ) -> None:
@@ -802,11 +831,29 @@ class App:
802
831
  self.user_sessions[sid] = session
803
832
  return session
804
833
 
834
+ def _get_render_for_session(
835
+ self, render_id: str | None, session: UserSession
836
+ ) -> RenderSession | None:
837
+ """
838
+ Get an existing render session for the given session, validating ownership.
839
+ Returns None if render_id is None, render doesn't exist, or doesn't belong to session.
840
+ """
841
+ if not render_id:
842
+ return None
843
+ render = self.render_sessions.get(render_id)
844
+ if render is None:
845
+ return None
846
+ owner = self._render_to_user.get(render_id)
847
+ if owner != session.sid:
848
+ return None
849
+ return render
850
+
805
851
  def create_render(
806
852
  self, rid: str, session: UserSession, *, client_address: str | None = None
807
853
  ):
808
854
  if rid in self.render_sessions:
809
855
  raise ValueError(f"RenderSession {rid} already exists")
856
+ print(f"Creating RenderSession {rid}")
810
857
  render = RenderSession(
811
858
  rid,
812
859
  self.routes,
@@ -819,9 +866,13 @@ class App:
819
866
  return render
820
867
 
821
868
  def close_render(self, rid: str):
869
+ # Cancel any pending cleanup task
870
+ self._cancel_render_cleanup(rid)
871
+
822
872
  render = self.render_sessions.pop(rid, None)
823
873
  if not render:
824
874
  return
875
+ print(f"Closing RenderSession {rid}")
825
876
  sid = self._render_to_user.pop(rid)
826
877
  session = self.user_sessions[sid]
827
878
  render.close()
@@ -846,7 +897,12 @@ class App:
846
897
  This method is called automatically during shutdown.
847
898
  """
848
899
 
900
+ # Cancel all pending cleanup tasks
901
+ for rid in list(self._render_cleanups.keys()):
902
+ self._cancel_render_cleanup(rid)
903
+
849
904
  # Close all render sessions
905
+ print("Closing app")
850
906
  for rid in list(self.render_sessions.keys()):
851
907
  self.close_render(rid)
852
908
 
@@ -883,7 +939,7 @@ class App:
883
939
  return # no active render for this user session
884
940
 
885
941
  # We don't want to wait for this to resolve
886
- create_task(render.call_api("/set-cookies", method="GET"))
942
+ create_task(render.call_api(f"/{self.api_prefix}/set-cookies", method="GET"))
887
943
  sess.scheduled_cookie_refresh = True
888
944
 
889
945
 
@@ -85,9 +85,7 @@ export default function PulseLayout() {
85
85
  const data = useLoaderData<typeof loader>();
86
86
  if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") {
87
87
  sessionStorage.setItem("__PULSE_RENDER_ID", data.renderId);
88
- if (data.directives) {
89
- sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
90
- }
88
+ sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
91
89
  }
92
90
  return (
93
91
  <PulseProvider config={config} prerender={data}>