pulse-framework 0.1.38a5__tar.gz → 0.1.38a6__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.38a5 → pulse_framework-0.1.38a6}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/__init__.py +4 -0
  4. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/app.py +59 -21
  5. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/templates/layout.py +20 -3
  6. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/messages.py +22 -0
  7. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/middleware.py +64 -3
  8. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/plugin.py +1 -0
  9. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/README.md +0 -0
  10. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/channel.py +0 -0
  11. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/__init__.py +0 -0
  12. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/cmd.py +0 -0
  13. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/dependencies.py +0 -0
  14. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/folder_lock.py +0 -0
  15. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/helpers.py +0 -0
  16. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/models.py +0 -0
  17. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/packages.py +0 -0
  18. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/processes.py +0 -0
  19. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/secrets.py +0 -0
  20. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cli/uvicorn_log_config.py +0 -0
  21. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/__init__.py +0 -0
  22. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/codegen.py +0 -0
  23. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/imports.py +0 -0
  24. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/js.py +0 -0
  25. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/templates/__init__.py +0 -0
  26. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/templates/route.py +0 -0
  27. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/templates/routes_ts.py +0 -0
  28. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/codegen/utils.py +0 -0
  29. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/components/__init__.py +0 -0
  30. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/components/for_.py +0 -0
  31. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/components/if_.py +0 -0
  32. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/components/react_router.py +0 -0
  33. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/context.py +0 -0
  34. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/cookies.py +0 -0
  35. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/css.py +0 -0
  36. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/decorators.py +0 -0
  37. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/env.py +0 -0
  38. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/form.py +0 -0
  39. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/helpers.py +0 -0
  40. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/__init__.py +0 -0
  41. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/core.py +0 -0
  42. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/effects.py +0 -0
  43. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/runtime.py +0 -0
  44. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/setup.py +0 -0
  45. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/stable.py +0 -0
  46. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/hooks/states.py +0 -0
  47. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/__init__.py +0 -0
  48. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/elements.py +0 -0
  49. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/events.py +0 -0
  50. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/props.py +0 -0
  51. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/svg.py +0 -0
  52. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/tags.py +0 -0
  53. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/html/tags.pyi +0 -0
  54. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/proxy.py +0 -0
  55. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/py.typed +0 -0
  56. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/query.py +0 -0
  57. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/react_component.py +0 -0
  58. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/reactive.py +0 -0
  59. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/reactive_extensions.py +0 -0
  60. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/render_session.py +0 -0
  61. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/renderer.py +0 -0
  62. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/request.py +0 -0
  63. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/routing.py +0 -0
  64. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/serializer.py +0 -0
  65. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/state.py +0 -0
  66. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/types/__init__.py +0 -0
  67. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/types/event_handler.py +0 -0
  68. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/user_session.py +0 -0
  69. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/src/pulse/vdom.py +0 -0
  70. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a6}/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.38a5
3
+ Version: 0.1.38a6
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.38a5"
3
+ version = "0.1.38a6"
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"
@@ -1300,6 +1300,10 @@ from pulse.html.tags import (
1300
1300
  from pulse.html.tags import (
1301
1301
  wbr as wbr,
1302
1302
  )
1303
+ from pulse.messages import Directives as Directives
1304
+ from pulse.messages import PrerenderPayload as PrerenderPayload
1305
+ from pulse.messages import PrerenderResult as PrerenderResult
1306
+ from pulse.messages import SocketIODirectives as SocketIODirectives
1303
1307
 
1304
1308
  # Middleware
1305
1309
  from pulse.middleware import (
@@ -11,7 +11,7 @@ from collections import defaultdict
11
11
  from collections.abc import Awaitable, Sequence
12
12
  from contextlib import asynccontextmanager
13
13
  from enum import IntEnum
14
- from typing import Any, Callable, Literal, NotRequired, TypedDict, TypeVar, cast
14
+ from typing import Any, Callable, Literal, TypeVar, cast
15
15
 
16
16
  import socketio
17
17
  import uvicorn
@@ -56,7 +56,8 @@ from pulse.messages import (
56
56
  ClientChannelResponseMessage,
57
57
  ClientMessage,
58
58
  ClientPulseMessage,
59
- ServerInitMessage,
59
+ PrerenderPayload,
60
+ PrerenderResult,
60
61
  ServerMessage,
61
62
  )
62
63
  from pulse.middleware import (
@@ -73,7 +74,7 @@ from pulse.proxy import PulseProxy
73
74
  from pulse.react_component import ReactComponent, registered_react_components
74
75
  from pulse.render_session import RenderSession
75
76
  from pulse.request import PulseRequest
76
- from pulse.routing import Layout, Route, RouteInfo, RouteTree
77
+ from pulse.routing import Layout, Route, RouteTree
77
78
  from pulse.serializer import Serialized, deserialize, serialize
78
79
  from pulse.user_session import (
79
80
  CookieSessionStore,
@@ -91,24 +92,13 @@ class AppStatus(IntEnum):
91
92
  created = 0
92
93
  initialized = 1
93
94
  running = 2
94
- stopped = 3
95
+ draining = 3
96
+ stopped = 4
95
97
 
96
98
 
97
99
  PulseMode = Literal["subdomains", "single-server"]
98
100
 
99
101
 
100
- class PrerenderPayload(TypedDict):
101
- paths: list[str]
102
- routeInfo: RouteInfo
103
- ttlSeconds: NotRequired[float | int]
104
- renderId: NotRequired[str]
105
-
106
-
107
- class PrerenderResult(TypedDict):
108
- renderId: str
109
- views: dict[str, ServerInitMessage | None]
110
-
111
-
112
102
  class App:
113
103
  """
114
104
  Pulse UI Application - the main entry point for defining your app.
@@ -277,6 +267,11 @@ class App:
277
267
  try:
278
268
  yield
279
269
  finally:
270
+ try:
271
+ await self.close()
272
+ except Exception:
273
+ logger.exception("Error during App.close()")
274
+
280
275
  try:
281
276
  if isinstance(self.session_store, SessionStore):
282
277
  await self.session_store.close()
@@ -380,7 +375,7 @@ class App:
380
375
 
381
376
  # Debug middleware to log CORS-related request details
382
377
  # @self.fastapi.middleware("http")
383
- # async def cors_debug_middleware( # pyright: ignore[reportUnusedFunction]
378
+ # async def cors_debug_middleware(
384
379
  # request: Request, call_next: Callable[[Request], Awaitable[Response]]
385
380
  # ):
386
381
  # origin = request.headers.get("origin")
@@ -483,7 +478,13 @@ class App:
483
478
  )
484
479
  cleanup = True
485
480
 
486
- result: PrerenderResult = {"renderId": render_id, "views": {}}
481
+ initial_result: PrerenderResult = {
482
+ "views": {},
483
+ "directives": {
484
+ "headers": {"X-Pulse-Render-Id": render_id},
485
+ "socketio": {"auth": {"render_id": render_id}, "headers": {}},
486
+ },
487
+ }
487
488
 
488
489
  def _prerender_one(path: str):
489
490
  captured = render.prerender_mount_capture(path, route_info)
@@ -499,10 +500,12 @@ class App:
499
500
  # Fallback: shouldn't happen, return not found to be safe
500
501
  return NotFound()
501
502
 
503
+ result = initial_result.copy()
504
+
502
505
  with PulseContext.update(render=render):
503
506
  for p in paths:
504
507
  try:
505
- res = self.middleware.prerender(
508
+ res = self.middleware.prerender_route(
506
509
  path=p,
507
510
  route_info=route_info,
508
511
  request=PulseRequest.from_fastapi(request),
@@ -545,7 +548,19 @@ class App:
545
548
  if cleanup:
546
549
  later(float(ttl), _gc_if_unadopted, render_id)
547
550
 
548
- resp = JSONResponse(serialize(result))
551
+ # Call top-level batch prerender middleware to modify final result
552
+ def _return_result() -> PrerenderResult:
553
+ return result
554
+
555
+ final_result = self.middleware.prerender(
556
+ payload=payload,
557
+ result=result,
558
+ request=PulseRequest.from_fastapi(request),
559
+ session=session.data,
560
+ next=_return_result,
561
+ )
562
+
563
+ resp = JSONResponse(serialize(final_result))
549
564
  session.handle_response(resp)
550
565
  return resp
551
566
 
@@ -572,7 +587,7 @@ class App:
572
587
  sid: str, environ: dict[str, Any], auth: dict[str, str] | None
573
588
  ):
574
589
  # Expect renderId during websocket auth and require a valid user session
575
- rid = auth.get("renderId") if auth else None
590
+ rid = auth.get("render_id") if auth else None
576
591
 
577
592
  # Parse cookies from environ and ensure a session exists
578
593
  cookie = self.cookie.get_from_socketio(environ)
@@ -825,6 +840,29 @@ class App:
825
840
  if len(self._user_to_render[sid]) == 0:
826
841
  self.close_session(sid)
827
842
 
843
+ async def close(self):
844
+ """
845
+ Close the app and clean up all sessions.
846
+ This method is called automatically during shutdown.
847
+ """
848
+
849
+ # Close all render sessions
850
+ for rid in list(self.render_sessions.keys()):
851
+ self.close_render(rid)
852
+
853
+ # Close all user sessions
854
+ for sid in list(self.user_sessions.keys()):
855
+ self.close_session(sid)
856
+
857
+ # Update status
858
+ self.status = AppStatus.stopped
859
+ # Call plugin on_shutdown hooks before closing
860
+ for plugin in self.plugins:
861
+ try:
862
+ plugin.on_shutdown(self)
863
+ except Exception:
864
+ logger.exception("Error during plugin.on_shutdown()")
865
+
828
866
  def refresh_cookies(self, sid: str):
829
867
  # If the session is currently inside an HTTP request, we don't need to schedule
830
868
  # set-cookies via WS; cookies will be attached on the HTTP response.
@@ -54,9 +54,19 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
54
54
  typeof window !== "undefined" && typeof sessionStorage !== "undefined"
55
55
  ? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
56
56
  : undefined;
57
+ const directives =
58
+ typeof window !== "undefined" && typeof sessionStorage !== "undefined"
59
+ ? (JSON.parse(sessionStorage.getItem("__PULSE_DIRECTIVES") ?? "{}"))
60
+ : {};
61
+ const headers: HeadersInit = { "content-type": "application/json" };
62
+ if (directives?.headers) {
63
+ for (const [key, value] of Object.entries(directives.headers)) {
64
+ headers[key] = value as string;
65
+ }
66
+ }
57
67
  const res = await fetch(`$${"{"}config.serverAddress}$${"{"}config.apiPrefix}/prerender`, {
58
68
  method: "POST",
59
- headers: { "content-type": "application/json" },
69
+ headers,
60
70
  credentials: "include",
61
71
  body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args), renderId }),
62
72
  });
@@ -64,13 +74,20 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
64
74
  const body = await res.json();
65
75
  if (body.redirect) return new Response(null, { status: 302, headers: { Location: body.redirect } });
66
76
  if (body.notFound) return new Response(null, { status: 404 });
67
- return deserialize(body) as PulsePrerender;
77
+ const prerenderData = deserialize(body) as PulsePrerender;
78
+ if (typeof window !== "undefined" && typeof sessionStorage !== "undefined" && prerenderData.directives) {
79
+ sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(prerenderData.directives));
80
+ }
81
+ return prerenderData as PulsePrerender;
68
82
  }
69
83
 
70
84
  export default function PulseLayout() {
71
85
  const data = useLoaderData<typeof loader>();
72
86
  if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") {
73
87
  sessionStorage.setItem("__PULSE_RENDER_ID", data.renderId);
88
+ if (data.directives) {
89
+ sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
90
+ }
74
91
  }
75
92
  return (
76
93
  <PulseProvider config={config} prerender={data}>
@@ -78,6 +95,6 @@ export default function PulseLayout() {
78
95
  </PulseProvider>
79
96
  );
80
97
  }
81
- // Persist renderId in sessionStorage for reuse in clientLoader is handled within the component
98
+ // Persist renderId and directives in sessionStorage for reuse in clientLoader is handled within the component
82
99
  """
83
100
  )
@@ -156,3 +156,25 @@ ClientPulseMessage = (
156
156
  )
157
157
  ClientChannelMessage = ClientChannelRequestMessage | ClientChannelResponseMessage
158
158
  ClientMessage = ClientPulseMessage | ClientChannelMessage
159
+
160
+
161
+ class PrerenderPayload(TypedDict):
162
+ paths: list[str]
163
+ routeInfo: RouteInfo
164
+ ttlSeconds: NotRequired[float | int]
165
+ renderId: NotRequired[str]
166
+
167
+
168
+ class SocketIODirectives(TypedDict):
169
+ headers: dict[str, str]
170
+ auth: dict[str, str]
171
+
172
+
173
+ class Directives(TypedDict):
174
+ headers: dict[str, str]
175
+ socketio: SocketIODirectives
176
+
177
+
178
+ class PrerenderResult(TypedDict):
179
+ views: dict[str, ServerInitMessage | None]
180
+ directives: Directives
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Callable, Sequence
4
- from typing import Any, Generic, TypeVar, overload, override
4
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload, override
5
5
 
6
6
  from pulse.messages import ClientMessage, ServerInitMessage
7
7
  from pulse.request import PulseRequest
8
8
  from pulse.routing import RouteInfo
9
9
 
10
+ if TYPE_CHECKING:
11
+ from pulse.app import PrerenderPayload, PrerenderResult
12
+
10
13
  T = TypeVar("T")
11
14
 
12
15
 
@@ -46,6 +49,22 @@ class PulseMiddleware:
46
49
  """
47
50
 
48
51
  def prerender(
52
+ self,
53
+ *,
54
+ payload: "PrerenderPayload",
55
+ result: "PrerenderResult",
56
+ request: PulseRequest,
57
+ session: dict[str, Any],
58
+ next: Callable[[], "PrerenderResult"],
59
+ ) -> "PrerenderResult":
60
+ """Handle batch prerender at the top level.
61
+
62
+ Receives the full PrerenderPayload and can modify the PrerenderResult
63
+ (views and directives) before it's returned to the client.
64
+ """
65
+ return next()
66
+
67
+ def prerender_route(
49
68
  self,
50
69
  *,
51
70
  path: str,
@@ -103,6 +122,34 @@ class MiddlewareStack(PulseMiddleware):
103
122
 
104
123
  @override
105
124
  def prerender(
125
+ self,
126
+ *,
127
+ payload: "PrerenderPayload",
128
+ result: "PrerenderResult",
129
+ request: PulseRequest,
130
+ session: dict[str, Any],
131
+ next: Callable[[], "PrerenderResult"],
132
+ ) -> "PrerenderResult":
133
+ def dispatch(index: int) -> "PrerenderResult":
134
+ if index >= len(self._middlewares):
135
+ return next()
136
+ mw = self._middlewares[index]
137
+
138
+ def _next() -> "PrerenderResult":
139
+ return dispatch(index + 1)
140
+
141
+ return mw.prerender(
142
+ payload=payload,
143
+ result=result,
144
+ request=request,
145
+ session=session,
146
+ next=_next,
147
+ )
148
+
149
+ return dispatch(0)
150
+
151
+ @override
152
+ def prerender_route(
106
153
  self,
107
154
  *,
108
155
  path: str,
@@ -119,7 +166,7 @@ class MiddlewareStack(PulseMiddleware):
119
166
  def _next() -> PrerenderResponse:
120
167
  return dispatch(index + 1)
121
168
 
122
- return mw.prerender(
169
+ return mw.prerender_route(
123
170
  path=path,
124
171
  route_info=route_info,
125
172
  request=request,
@@ -216,6 +263,20 @@ class PulseCoreMiddleware(PulseMiddleware):
216
263
  run, and finally returns their response unchanged.
217
264
  """
218
265
 
266
+ @override
267
+ def prerender(
268
+ self,
269
+ *,
270
+ payload: "PrerenderPayload",
271
+ result: "PrerenderResult",
272
+ request: PulseRequest,
273
+ session: dict[str, Any],
274
+ next: Callable[[], "PrerenderResult"],
275
+ ) -> "PrerenderResult":
276
+ res = next()
277
+ # Return the result as-is (no normalization needed)
278
+ return res
279
+
219
280
  # --- Normalization helpers -------------------------------------------------
220
281
  def _normalize_prerender_response(self, res: Any) -> PrerenderResponse:
221
282
  if isinstance(res, (Ok, Redirect, NotFound)):
@@ -236,7 +297,7 @@ class PulseCoreMiddleware(PulseMiddleware):
236
297
  return Ok(None)
237
298
 
238
299
  @override
239
- def prerender(
300
+ def prerender_route(
240
301
  self,
241
302
  *,
242
303
  path: str,
@@ -25,3 +25,4 @@ class Plugin:
25
25
  # Optional lifecycle
26
26
  def on_setup(self, app: App) -> None: ...
27
27
  def on_startup(self, app: App) -> None: ...
28
+ def on_shutdown(self, app: App) -> None: ...