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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/components/for_.py
ADDED
|
@@ -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)
|
pulse/components/if_.py
ADDED
|
@@ -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
|