pulse-framework 0.1.62__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 (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,83 @@
1
+ """For loop component for mapping items to elements.
2
+
3
+ Provides a declarative way to render lists, similar to JavaScript's Array.map().
4
+ """
5
+
6
+ from collections.abc import Callable, Iterable
7
+ from inspect import Parameter, signature
8
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
9
+
10
+ from pulse.transpiler.nodes import Call, Element, Expr, Member, transformer
11
+
12
+ if TYPE_CHECKING:
13
+ from pulse.transpiler.transpiler import Transpiler
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ @transformer("For")
19
+ def emit_for(items: Any, fn: Any, *, ctx: "Transpiler") -> Expr:
20
+ """For(items, fn) -> items.map(fn)"""
21
+ items_expr = ctx.emit_expr(items)
22
+ fn_expr = ctx.emit_expr(fn)
23
+ return Call(Member(items_expr, "map"), [fn_expr])
24
+
25
+
26
+ @overload
27
+ def For(items: Iterable[T], fn: Callable[[T], Element]) -> list[Element]: ...
28
+
29
+
30
+ @overload
31
+ def For(items: Iterable[T], fn: Callable[[T, int], Element]) -> list[Element]: ...
32
+
33
+
34
+ def For(items: Iterable[T], fn: Callable[..., Element]) -> list[Element]:
35
+ """Map items to elements, like JavaScript's Array.map().
36
+
37
+ Iterates over `items` and calls `fn` for each one, returning a list of
38
+ elements. The mapper function can accept either one argument (item) or
39
+ two arguments (item, index).
40
+
41
+ Args:
42
+ items: Iterable of items to map over.
43
+ fn: Mapper function that receives `(item)` or `(item, index)` and
44
+ returns an Element. If `fn` has a `*args` parameter, it receives
45
+ both item and index.
46
+
47
+ Returns:
48
+ A list of Elements, one for each item.
49
+
50
+ Example:
51
+ Single argument (item only)::
52
+
53
+ ps.For(users, lambda user: UserCard(user=user, key=user.id))
54
+
55
+ With index::
56
+
57
+ ps.For(items, lambda item, i: ps.li(f"{i}: {item}", key=str(i)))
58
+
59
+ Note:
60
+ In transpiled `@javascript` code, `For` compiles to `.map()`.
61
+ """
62
+ try:
63
+ sig = signature(fn)
64
+ has_varargs = any(
65
+ p.kind == Parameter.VAR_POSITIONAL for p in sig.parameters.values()
66
+ )
67
+ num_positional = sum(
68
+ 1
69
+ for p in sig.parameters.values()
70
+ if p.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
71
+ )
72
+ accepts_two = has_varargs or num_positional >= 2
73
+ except (ValueError, TypeError):
74
+ # Builtins or callables without inspectable signature: default to single-arg
75
+ accepts_two = False
76
+
77
+ if accepts_two:
78
+ return [fn(item, idx) for idx, item in enumerate(items)]
79
+ return [fn(item) for item in items]
80
+
81
+
82
+ # Register For in EXPR_REGISTRY so it can be used in transpiled functions
83
+ Expr.register(For, emit_for)
@@ -0,0 +1,86 @@
1
+ """Conditional rendering component.
2
+
3
+ Provides a declarative way to conditionally render elements based on a condition.
4
+ """
5
+
6
+ from collections.abc import Iterable
7
+ from typing import Any, TypeVar
8
+
9
+ from pulse.reactive import Computed, Signal
10
+ from pulse.transpiler.nodes import Element
11
+
12
+ T1 = TypeVar("T1", bound=Element | Iterable[Element])
13
+ T2 = TypeVar("T2", bound=Element | Iterable[Element] | None)
14
+
15
+
16
+ def _is_truthy(value: Any) -> bool:
17
+ if isinstance(value, bool):
18
+ return value
19
+ if value is None:
20
+ return False
21
+ try:
22
+ return bool(value)
23
+ except Exception:
24
+ pass
25
+ # Fallbacks for array/dataframe-like values that have ambiguous truthiness
26
+ try:
27
+ return len(value) > 0 # type: ignore[arg-type]
28
+ except Exception:
29
+ pass
30
+ size = getattr(value, "size", None)
31
+ if isinstance(size, int):
32
+ return size > 0
33
+ empty = getattr(value, "empty", None)
34
+ if isinstance(empty, bool):
35
+ return not empty
36
+ # Conservative fallback
37
+ return False
38
+
39
+
40
+ def If(
41
+ condition: bool | Signal[bool] | Computed[bool],
42
+ then: T1,
43
+ else_: T2 = None,
44
+ ) -> T1 | T2:
45
+ """Conditional rendering helper.
46
+
47
+ Returns `then` if the condition is truthy, otherwise returns `else_`.
48
+ Automatically unwraps reactive values (Signal, Computed) before evaluation.
49
+
50
+ Args:
51
+ condition: A boolean or reactive value to evaluate. Supports `Signal[bool]`
52
+ and `Computed[bool]` which are automatically unwrapped.
53
+ then: Element to render when condition is truthy.
54
+ else_: Element to render when condition is falsy. Defaults to None.
55
+
56
+ Returns:
57
+ The `then` value if condition is truthy, otherwise `else_`.
58
+
59
+ Example:
60
+ Basic conditional::
61
+
62
+ ps.If(
63
+ user.is_admin,
64
+ then=AdminPanel(),
65
+ else_=ps.p("Access denied"),
66
+ )
67
+
68
+ With reactive condition::
69
+
70
+ is_visible = ps.Signal(True)
71
+ ps.If(is_visible, then=ps.div("Content"))
72
+ """
73
+ # Unwrap reactive condition if needed and coerce to bool explicitly with guards
74
+ if isinstance(condition, (Signal, Computed)):
75
+ try:
76
+ raw = condition.unwrap() # type: ignore[attr-defined]
77
+ except Exception:
78
+ try:
79
+ raw = condition()
80
+ except Exception:
81
+ raw = condition
82
+ else:
83
+ raw = condition
84
+ if _is_truthy(raw):
85
+ return then
86
+ return else_
@@ -0,0 +1,94 @@
1
+ """React Router components for client-side navigation.
2
+
3
+ Provides Pulse bindings for react-router's Link and Outlet components.
4
+ """
5
+
6
+ from typing import Literal, TypedDict, Unpack
7
+
8
+ from pulse.dom.props import HTMLAnchorProps
9
+ from pulse.react_component import react_component
10
+ from pulse.transpiler import Import
11
+ from pulse.transpiler.nodes import Node
12
+
13
+
14
+ class LinkPath(TypedDict):
15
+ """TypedDict for Link's `to` prop when using an object instead of string."""
16
+
17
+ pathname: str
18
+ search: str
19
+ hash: str
20
+
21
+
22
+ @react_component(Import("Link", "react-router", version="^7"))
23
+ def Link(
24
+ *children: Node,
25
+ key: str | None = None,
26
+ to: str,
27
+ discover: Literal["render", "none"] | None = None,
28
+ prefetch: Literal["none", "intent", "render", "viewport"] = "intent",
29
+ preventScrollReset: bool | None = None,
30
+ relative: Literal["route", "path"] | None = None,
31
+ reloadDocument: bool | None = None,
32
+ replace: bool | None = None,
33
+ state: dict[str, object] | None = None,
34
+ viewTransition: bool | None = None,
35
+ **props: Unpack[HTMLAnchorProps],
36
+ ) -> None:
37
+ """Client-side navigation link using react-router.
38
+
39
+ Renders an anchor tag that performs client-side navigation without a full
40
+ page reload. Supports prefetching and various navigation behaviors.
41
+
42
+ Args:
43
+ *children: Content to render inside the link.
44
+ key: React reconciliation key.
45
+ to: The target URL path (e.g., "/dashboard", "/users/123").
46
+ discover: Route discovery behavior. "render" discovers on render,
47
+ "none" disables discovery.
48
+ prefetch: Prefetch strategy. "intent" (default) prefetches on hover/focus,
49
+ "render" prefetches immediately, "viewport" when visible, "none" disables.
50
+ preventScrollReset: If True, prevents scroll position reset on navigation.
51
+ relative: Path resolution mode. "route" resolves relative to route hierarchy,
52
+ "path" resolves relative to URL path.
53
+ reloadDocument: If True, performs a full page navigation instead of SPA.
54
+ replace: If True, replaces current history entry instead of pushing.
55
+ state: Arbitrary state to pass to the destination location.
56
+ viewTransition: If True, enables View Transitions API for the navigation.
57
+ **props: Additional HTML anchor attributes (className, onClick, etc.).
58
+
59
+ Example:
60
+ Basic navigation::
61
+
62
+ ps.Link(to="/dashboard")["Go to Dashboard"]
63
+
64
+ With prefetching disabled::
65
+
66
+ ps.Link(to="/settings", prefetch="none")["Settings"]
67
+ """
68
+ ...
69
+
70
+
71
+ @react_component(Import("Outlet", "react-router", version="^7"))
72
+ def Outlet(key: str | None = None) -> None:
73
+ """Renders the matched child route's element.
74
+
75
+ Outlet is used in parent route components to render their child routes.
76
+ It acts as a placeholder where nested route content will be displayed.
77
+
78
+ Args:
79
+ key: React reconciliation key.
80
+
81
+ Example:
82
+ Layout with outlet for child routes::
83
+
84
+ @ps.component
85
+ def Layout():
86
+ return ps.div(
87
+ ps.nav("Navigation"),
88
+ ps.Outlet(), # Child route renders here
89
+ )
90
+ """
91
+ ...
92
+
93
+
94
+ __all__ = ["Link", "Outlet"]
pulse/context.py ADDED
@@ -0,0 +1,108 @@
1
+ # pyright: reportImportCycles=false
2
+ from contextvars import ContextVar, Token
3
+ from dataclasses import dataclass
4
+ from types import TracebackType
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from pulse.routing import RouteContext
8
+
9
+ if TYPE_CHECKING:
10
+ from pulse.app import App
11
+ from pulse.render_session import RenderSession
12
+ from pulse.user_session import UserSession
13
+
14
+
15
+ @dataclass
16
+ class PulseContext:
17
+ """Composite context accessible to hooks and internals.
18
+
19
+ Manages per-request state via context variables. Provides access to the
20
+ application instance, user session, render session, and route context.
21
+
22
+ Attributes:
23
+ app: Application instance.
24
+ session: Per-user session (UserSession or None).
25
+ render: Per-connection render session (RenderSession or None).
26
+ route: Active route context (RouteContext or None).
27
+
28
+ Example:
29
+ ```python
30
+ ctx = PulseContext(app=app, session=session)
31
+ with ctx:
32
+ # Context is active here
33
+ current = PulseContext.get()
34
+ # Previous context restored
35
+ ```
36
+ """
37
+
38
+ app: "App"
39
+ session: "UserSession | None" = None
40
+ render: "RenderSession | None" = None
41
+ route: "RouteContext | None" = None
42
+ _token: "Token[PulseContext | None] | None" = None
43
+
44
+ @classmethod
45
+ def get(cls) -> "PulseContext":
46
+ """Get the current context.
47
+
48
+ Returns:
49
+ Current PulseContext instance.
50
+
51
+ Raises:
52
+ RuntimeError: If no context is active.
53
+ """
54
+ ctx = PULSE_CONTEXT.get()
55
+ if ctx is None:
56
+ raise RuntimeError("Internal error: PULSE_CONTEXT is not set")
57
+ return ctx
58
+
59
+ @classmethod
60
+ def update(
61
+ cls,
62
+ session: "UserSession | None" = None,
63
+ render: "RenderSession | None" = None,
64
+ route: "RouteContext | None" = None,
65
+ ) -> "PulseContext":
66
+ """Create a new context with updated values.
67
+
68
+ Inherits unspecified values from the current context.
69
+
70
+ Args:
71
+ session: New session (optional, inherits if not provided).
72
+ render: New render session (optional, inherits if not provided).
73
+ route: New route context (optional, inherits if not provided).
74
+
75
+ Returns:
76
+ New PulseContext instance with updated values.
77
+ """
78
+ ctx = cls.get()
79
+ return PulseContext(
80
+ app=ctx.app,
81
+ session=session or ctx.session,
82
+ render=render or ctx.render,
83
+ route=route or ctx.route,
84
+ )
85
+
86
+ def __enter__(self):
87
+ self._token = PULSE_CONTEXT.set(self)
88
+ return self
89
+
90
+ def __exit__(
91
+ self,
92
+ exc_type: type[BaseException] | None = None,
93
+ exc_val: BaseException | None = None,
94
+ exc_tb: TracebackType | None = None,
95
+ ) -> Literal[False]:
96
+ if self._token is not None:
97
+ PULSE_CONTEXT.reset(self._token)
98
+ self._token = None
99
+ return False
100
+
101
+
102
+ PULSE_CONTEXT: ContextVar["PulseContext | None"] = ContextVar(
103
+ "pulse_context", default=None
104
+ )
105
+
106
+ __all__ = [
107
+ "PULSE_CONTEXT",
108
+ ]
pulse/cookies.py ADDED
@@ -0,0 +1,322 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import KW_ONLY, dataclass
3
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict
4
+ from urllib.parse import urlparse
5
+
6
+ from fastapi import Request, Response
7
+
8
+ from pulse.env import PulseEnv
9
+ from pulse.hooks.runtime import set_cookie
10
+
11
+ if TYPE_CHECKING:
12
+ from pulse.app import PulseMode
13
+
14
+
15
+ @dataclass
16
+ class Cookie:
17
+ """Configuration for HTTP cookies used in session management.
18
+
19
+ Attributes:
20
+ name: Cookie name.
21
+ domain: Cookie domain. Set automatically in subdomain mode.
22
+ secure: HTTPS-only flag. Auto-resolved from server address if None.
23
+ samesite: SameSite attribute ("lax", "strict", or "none").
24
+ max_age_seconds: Cookie lifetime in seconds (default 7 days).
25
+
26
+ Example:
27
+ ```python
28
+ cookie = Cookie(
29
+ name="session",
30
+ secure=True,
31
+ samesite="strict",
32
+ max_age_seconds=3600,
33
+ )
34
+ ```
35
+ """
36
+
37
+ name: str
38
+ _: KW_ONLY
39
+ domain: str | None = None
40
+ secure: bool | None = None
41
+ samesite: Literal["lax", "strict", "none"] = "lax"
42
+ max_age_seconds: int = 7 * 24 * 3600
43
+
44
+ def get_from_fastapi(self, request: Request) -> str | None:
45
+ """Extract cookie value from a FastAPI Request.
46
+
47
+ Reads the Cookie header and parses it to find this cookie's value.
48
+
49
+ Args:
50
+ request: FastAPI/Starlette Request object.
51
+
52
+ Returns:
53
+ Cookie value if found, None otherwise.
54
+ """
55
+ header = request.headers.get("cookie")
56
+ cookies = parse_cookie_header(header)
57
+ return cookies.get(self.name)
58
+
59
+ def get_from_socketio(self, environ: dict[str, Any]) -> str | None:
60
+ """Extract cookie value from a Socket.IO environ mapping.
61
+
62
+ Args:
63
+ environ: Socket.IO environ dictionary.
64
+
65
+ Returns:
66
+ Cookie value if found, None otherwise.
67
+ """
68
+ raw = environ.get("HTTP_COOKIE") or environ.get("COOKIE")
69
+ cookies = parse_cookie_header(raw)
70
+ return cookies.get(self.name)
71
+
72
+ async def set_through_api(self, value: str) -> None:
73
+ """Set the cookie on the client via WebSocket.
74
+
75
+ Must be called during a callback context.
76
+
77
+ Args:
78
+ value: Cookie value to set.
79
+
80
+ Raises:
81
+ RuntimeError: If Cookie.secure is not resolved (ensure App.setup()
82
+ ran first).
83
+ """
84
+ if self.secure is None:
85
+ raise RuntimeError(
86
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
87
+ )
88
+ await set_cookie(
89
+ name=self.name,
90
+ value=value,
91
+ domain=self.domain,
92
+ secure=self.secure,
93
+ samesite=self.samesite,
94
+ max_age_seconds=self.max_age_seconds,
95
+ )
96
+
97
+ def set_on_fastapi(self, response: Response, value: str) -> None:
98
+ """Set the cookie on a FastAPI Response object.
99
+
100
+ Configured with httponly=True and path="/".
101
+
102
+ Args:
103
+ response: FastAPI Response object.
104
+ value: Cookie value to set.
105
+
106
+ Raises:
107
+ RuntimeError: If Cookie.secure is not resolved.
108
+ """
109
+ if self.secure is None:
110
+ raise RuntimeError(
111
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
112
+ )
113
+ response.set_cookie(
114
+ key=self.name,
115
+ value=value,
116
+ httponly=True,
117
+ samesite=self.samesite,
118
+ secure=self.secure,
119
+ max_age=self.max_age_seconds,
120
+ domain=self.domain,
121
+ path="/",
122
+ )
123
+
124
+
125
+ @dataclass
126
+ class SetCookie(Cookie):
127
+ """Extended Cookie dataclass that includes the cookie value.
128
+
129
+ Used for setting cookies with a specific value. Inherits all configuration
130
+ from Cookie.
131
+
132
+ Attributes:
133
+ value: The cookie value to set.
134
+ """
135
+
136
+ value: str
137
+
138
+ @classmethod
139
+ def from_cookie(cls, cookie: Cookie, value: str) -> "SetCookie":
140
+ """Create a SetCookie from an existing Cookie configuration.
141
+
142
+ Args:
143
+ cookie: Cookie configuration to copy settings from.
144
+ value: Cookie value to set.
145
+
146
+ Returns:
147
+ SetCookie instance with the same configuration and specified value.
148
+
149
+ Raises:
150
+ RuntimeError: If cookie.secure is not resolved.
151
+ """
152
+ if cookie.secure is None:
153
+ raise RuntimeError(
154
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
155
+ )
156
+ return cls(
157
+ name=cookie.name,
158
+ value=value,
159
+ domain=cookie.domain,
160
+ secure=cookie.secure,
161
+ samesite=cookie.samesite,
162
+ max_age_seconds=cookie.max_age_seconds,
163
+ )
164
+
165
+
166
+ def session_cookie(
167
+ mode: "PulseMode",
168
+ name: str = "pulse.sid",
169
+ max_age_seconds: int = 7 * 24 * 3600,
170
+ ):
171
+ if mode == "single-server":
172
+ return Cookie(
173
+ name,
174
+ domain=None,
175
+ secure=None,
176
+ samesite="lax",
177
+ max_age_seconds=max_age_seconds,
178
+ )
179
+ elif mode == "subdomains":
180
+ return Cookie(
181
+ name,
182
+ domain=None, # to be set later
183
+ secure=True,
184
+ samesite="lax",
185
+ max_age_seconds=max_age_seconds,
186
+ )
187
+ else:
188
+ raise ValueError(f"Unexpected cookie mode: '{mode}'")
189
+
190
+
191
+ class CORSOptions(TypedDict, total=False):
192
+ """TypedDict for CORS middleware configuration.
193
+
194
+ Attributes:
195
+ allow_origins: List of allowed origins. Use ['*'] for all. Default: ().
196
+ allow_methods: List of allowed HTTP methods. Default: ('GET',).
197
+ allow_headers: List of allowed HTTP headers. Default: ().
198
+ allow_credentials: Whether to allow credentials. Default: False.
199
+ allow_origin_regex: Regex pattern for allowed origins. Default: None.
200
+ expose_headers: List of headers to expose to browser. Default: ().
201
+ max_age: Browser CORS cache duration in seconds. Default: 600.
202
+ """
203
+
204
+ allow_origins: Sequence[str]
205
+ "List of allowed origins. Use ['*'] to allow all origins. Default: ()"
206
+
207
+ allow_methods: Sequence[str]
208
+ "List of allowed HTTP methods. Use ['*'] to allow all methods. Default: ('GET',)"
209
+
210
+ allow_headers: Sequence[str]
211
+ "List of allowed HTTP headers. Use ['*'] to allow all headers. Default: ()"
212
+
213
+ allow_credentials: bool
214
+ "Whether to allow credentials (cookies, authorization headers etc). Default: False"
215
+
216
+ allow_origin_regex: str | None
217
+ "Regex pattern for allowed origins. Alternative to allow_origins list. Default: None"
218
+
219
+ expose_headers: Sequence[str]
220
+ "List of headers to expose to the browser. Default: ()"
221
+
222
+ max_age: int
223
+ "How long browsers should cache CORS responses, in seconds. Default: 600"
224
+
225
+
226
+ def _parse_host(server_address: str) -> str | None:
227
+ try:
228
+ if not server_address:
229
+ return None
230
+ host = urlparse(server_address).hostname
231
+ return host
232
+ except Exception:
233
+ return None
234
+
235
+
236
+ def _base_domain(host: str) -> str:
237
+ # Simplified rule: drop the leftmost label, keep everything to the right.
238
+ # Assumes host is a subdomain (e.g., api.example.com -> example.com).
239
+ i = host.find(".")
240
+ return host[i + 1 :] if i != -1 else host
241
+
242
+
243
+ def compute_cookie_domain(mode: "PulseMode", server_address: str) -> str | None:
244
+ host = _parse_host(server_address)
245
+ if mode == "single-server" or not host:
246
+ return None
247
+ if mode == "subdomains":
248
+ return "." + _base_domain(host)
249
+ return None
250
+
251
+
252
+ def compute_cookie_secure(env: PulseEnv, server_address: str | None) -> bool:
253
+ scheme = urlparse(server_address or "").scheme.lower()
254
+ if scheme in ("https", "wss"):
255
+ secure = True
256
+ elif scheme in ("http", "ws"):
257
+ secure = False
258
+ else:
259
+ secure = None
260
+ if secure is None:
261
+ if env in ("prod", "ci"):
262
+ raise RuntimeError(
263
+ "Could not determine cookie security from server_address. "
264
+ + "Use an explicit https:// server_address or set Cookie(secure=True/False)."
265
+ )
266
+ return False
267
+ if env in ("prod", "ci") and not secure:
268
+ raise RuntimeError(
269
+ "Refusing to use insecure cookies in prod/ci. "
270
+ + "Use an https server_address or set Cookie(secure=True) explicitly."
271
+ )
272
+ return secure
273
+
274
+
275
+ def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
276
+ host = _parse_host(server_address) or "localhost"
277
+ opts: CORSOptions = {
278
+ "allow_credentials": True,
279
+ "allow_methods": ["*"],
280
+ "allow_headers": ["*"],
281
+ }
282
+ if mode == "subdomains":
283
+ base = _base_domain(host)
284
+ # Escape dots in base domain for regex (doesn't affect localhost since it has no dots)
285
+ base = base.replace(".", r"\.")
286
+ # Allow any subdomain and any port for the base domain
287
+ opts["allow_origin_regex"] = rf"^https?://([a-z0-9-]+\\.)?{base}(:\\d+)?$"
288
+ return opts
289
+ elif mode == "single-server":
290
+ # For single-server mode, allow same origin
291
+ # Escape dots in host for regex (doesn't affect localhost since it has no dots)
292
+ host = host.replace(".", r"\.")
293
+ opts["allow_origin_regex"] = rf"^https?://{host}(:\\d+)?$"
294
+ return opts
295
+ else:
296
+ raise ValueError(f"Unsupported deployment mode '{mode}'")
297
+
298
+
299
+ def parse_cookie_header(header: str | None) -> dict[str, str]:
300
+ """Parse a raw Cookie header string into a dictionary.
301
+
302
+ Args:
303
+ header: Raw Cookie header string (e.g., "session=abc123; theme=dark").
304
+
305
+ Returns:
306
+ Dictionary of cookie name-value pairs.
307
+
308
+ Example:
309
+ ```python
310
+ cookies = parse_cookie_header("session=abc123; theme=dark")
311
+ # {"session": "abc123", "theme": "dark"}
312
+ ```
313
+ """
314
+ cookies: dict[str, str] = {}
315
+ if not header:
316
+ return cookies
317
+ parts = [p.strip() for p in header.split(";") if p.strip()]
318
+ for part in parts:
319
+ if "=" in part:
320
+ k, v = part.split("=", 1)
321
+ cookies[k.strip()] = v.strip()
322
+ return cookies