pulse-framework 0.1.55__py3-none-any.whl → 0.1.56__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 +5 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/codegen/codegen.py +43 -12
- pulse/component.py +104 -0
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +40 -24
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +94 -55
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +0 -3
- pulse/transpiler/py_module.py +1 -7
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/transpiler/react_component.py +0 -44
- pulse_framework-0.1.55.dist-info/RECORD +0 -127
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/codegen/codegen.py
CHANGED
|
@@ -24,13 +24,32 @@ logger = logging.getLogger(__file__)
|
|
|
24
24
|
|
|
25
25
|
@dataclass
|
|
26
26
|
class CodegenConfig:
|
|
27
|
-
"""
|
|
28
|
-
|
|
27
|
+
"""Configuration for code generation output paths.
|
|
28
|
+
|
|
29
|
+
Controls where generated React Router files are written. All paths
|
|
30
|
+
can be relative (resolved against base_dir) or absolute.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
web_dir: Root directory for web output. Defaults to "web".
|
|
34
|
+
pulse_dir: Subdirectory for generated Pulse files. Defaults to "pulse".
|
|
35
|
+
base_dir: Base directory for resolving relative paths. If not provided,
|
|
36
|
+
resolved from PULSE_APP_FILE, PULSE_APP_DIR, or cwd.
|
|
29
37
|
|
|
30
38
|
Attributes:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
web_dir: Root directory for web output.
|
|
40
|
+
pulse_dir: Subdirectory name for generated files.
|
|
41
|
+
base_dir: Explicit base directory, if provided.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
```python
|
|
45
|
+
app = ps.App(
|
|
46
|
+
codegen=ps.CodegenConfig(
|
|
47
|
+
web_dir="frontend",
|
|
48
|
+
pulse_dir="generated",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
# Generated files will be at: frontend/app/generated/
|
|
52
|
+
```
|
|
34
53
|
"""
|
|
35
54
|
|
|
36
55
|
web_dir: Path | str = "web"
|
|
@@ -46,11 +65,14 @@ class CodegenConfig:
|
|
|
46
65
|
def resolved_base_dir(self) -> Path:
|
|
47
66
|
"""Resolve the base directory where relative paths should be anchored.
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
Returns:
|
|
69
|
+
Resolved base directory path.
|
|
70
|
+
|
|
71
|
+
Resolution precedence:
|
|
72
|
+
1. Explicit `base_dir` if provided
|
|
73
|
+
2. Directory of PULSE_APP_FILE env var
|
|
74
|
+
3. PULSE_APP_DIR env var
|
|
75
|
+
4. Current working directory
|
|
54
76
|
"""
|
|
55
77
|
if isinstance(self.base_dir, Path):
|
|
56
78
|
return self.base_dir
|
|
@@ -64,7 +86,11 @@ class CodegenConfig:
|
|
|
64
86
|
|
|
65
87
|
@property
|
|
66
88
|
def web_root(self) -> Path:
|
|
67
|
-
"""Absolute path to the web root directory
|
|
89
|
+
"""Absolute path to the web root directory.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Absolute path to web_dir (e.g., `<base_dir>/web`).
|
|
93
|
+
"""
|
|
68
94
|
wd = Path(self.web_dir)
|
|
69
95
|
if wd.is_absolute():
|
|
70
96
|
return wd
|
|
@@ -72,7 +98,12 @@ class CodegenConfig:
|
|
|
72
98
|
|
|
73
99
|
@property
|
|
74
100
|
def pulse_path(self) -> Path:
|
|
75
|
-
"""Full path to the generated app directory.
|
|
101
|
+
"""Full path to the generated Pulse app directory.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Absolute path where generated files are written
|
|
105
|
+
(e.g., `<web_root>/app/<pulse_dir>`).
|
|
106
|
+
"""
|
|
76
107
|
return self.web_root / "app" / self.pulse_dir
|
|
77
108
|
|
|
78
109
|
|
pulse/component.py
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
"""Component definition and VDOM node types for Pulse.
|
|
2
|
+
|
|
3
|
+
This module provides the core component abstraction for building Pulse UIs,
|
|
4
|
+
including the `@component` decorator and the `Component` class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
from __future__ import annotations
|
|
2
8
|
|
|
3
9
|
from collections.abc import Callable
|
|
4
10
|
from inspect import Parameter, signature
|
|
11
|
+
from types import CodeType
|
|
5
12
|
from typing import Any, Generic, ParamSpec, TypeVar, overload, override
|
|
6
13
|
|
|
7
14
|
from pulse.code_analysis import is_stub_function
|
|
@@ -19,14 +26,47 @@ from pulse.transpiler.vdom import VDOMNode
|
|
|
19
26
|
P = ParamSpec("P")
|
|
20
27
|
_T = TypeVar("_T")
|
|
21
28
|
|
|
29
|
+
_COMPONENT_CODES: set[CodeType] = set()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_component_code(code: CodeType) -> bool:
|
|
33
|
+
return code in _COMPONENT_CODES
|
|
34
|
+
|
|
22
35
|
|
|
23
36
|
class Component(Generic[P]):
|
|
37
|
+
"""A callable wrapper that turns a function into a Pulse component.
|
|
38
|
+
|
|
39
|
+
Component instances are created by the `@component` decorator. When called,
|
|
40
|
+
they return a `PulseNode` that represents the component in the virtual DOM.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
name: Display name of the component (defaults to function name).
|
|
44
|
+
fn: The underlying render function (lazily initialized for stubs).
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@ps.component
|
|
50
|
+
def Card(title: str):
|
|
51
|
+
return ps.div(ps.h3(title))
|
|
52
|
+
|
|
53
|
+
Card(title="Hello") # Returns a PulseNode
|
|
54
|
+
Card(title="Hello", key="card-1") # With reconciliation key
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
24
58
|
_raw_fn: Callable[P, Any]
|
|
25
59
|
_fn: Callable[P, Any] | None
|
|
26
60
|
name: str
|
|
27
61
|
_takes_children: bool | None
|
|
28
62
|
|
|
29
63
|
def __init__(self, fn: Callable[P, Any], name: str | None = None) -> None:
|
|
64
|
+
"""Initialize a Component.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
fn: The function to wrap as a component.
|
|
68
|
+
name: Custom display name. Defaults to the function's `__name__`.
|
|
69
|
+
"""
|
|
30
70
|
self._raw_fn = fn
|
|
31
71
|
self.name = name or _infer_component_name(fn)
|
|
32
72
|
# Only lazy-init for stubs (avoid heavy work for JS module bindings)
|
|
@@ -37,15 +77,31 @@ class Component(Generic[P]):
|
|
|
37
77
|
else:
|
|
38
78
|
self._fn = rewrite_init_blocks(fn)
|
|
39
79
|
self._takes_children = _takes_children(fn)
|
|
80
|
+
_COMPONENT_CODES.add(self._fn.__code__)
|
|
40
81
|
|
|
41
82
|
@property
|
|
42
83
|
def fn(self) -> Callable[P, Any]:
|
|
84
|
+
"""The render function (lazily initialized for stub functions)."""
|
|
43
85
|
if self._fn is None:
|
|
44
86
|
self._fn = rewrite_init_blocks(self._raw_fn)
|
|
45
87
|
self._takes_children = _takes_children(self._raw_fn)
|
|
88
|
+
_COMPONENT_CODES.add(self._fn.__code__)
|
|
46
89
|
return self._fn
|
|
47
90
|
|
|
48
91
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> PulseNode:
|
|
92
|
+
"""Invoke the component to create a PulseNode.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
*args: Positional arguments passed to the component function.
|
|
96
|
+
**kwargs: Keyword arguments passed to the component function.
|
|
97
|
+
The special `key` kwarg is used for reconciliation.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A PulseNode representing this component invocation in the VDOM.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If `key` is provided but is not a string.
|
|
104
|
+
"""
|
|
49
105
|
key = kwargs.get("key")
|
|
50
106
|
if key is not None and not isinstance(key, str):
|
|
51
107
|
raise ValueError("key must be a string or None")
|
|
@@ -85,6 +141,53 @@ def component(
|
|
|
85
141
|
def component(
|
|
86
142
|
fn: Callable[P, Any] | None = None, *, name: str | None = None
|
|
87
143
|
) -> Component[P] | Callable[[Callable[P, Any]], Component[P]]:
|
|
144
|
+
"""Decorator that creates a Pulse component from a function.
|
|
145
|
+
|
|
146
|
+
Can be used with or without parentheses. The decorated function becomes
|
|
147
|
+
callable and returns a `PulseNode` when invoked.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
fn: Function to wrap as a component. When used as `@component` without
|
|
151
|
+
parentheses, this is the decorated function.
|
|
152
|
+
name: Custom component name for debugging/dev tools. Defaults to the
|
|
153
|
+
function's `__name__`.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A `Component` instance if `fn` is provided, otherwise a decorator.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
|
|
160
|
+
Basic usage:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
@ps.component
|
|
164
|
+
def Card(title: str):
|
|
165
|
+
return ps.div(ps.h3(title))
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
With custom name:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
@ps.component(name="MyCard")
|
|
172
|
+
def card_impl(title: str):
|
|
173
|
+
return ps.div(ps.h3(title))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
With children (use `*children` parameter):
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@ps.component
|
|
180
|
+
def Container(*children):
|
|
181
|
+
return ps.div(*children, className="container")
|
|
182
|
+
|
|
183
|
+
# Children can be passed via subscript syntax:
|
|
184
|
+
Container()[
|
|
185
|
+
Card(title="First"),
|
|
186
|
+
Card(title="Second"),
|
|
187
|
+
]
|
|
188
|
+
```
|
|
189
|
+
"""
|
|
190
|
+
|
|
88
191
|
def decorator(fn: Callable[P, Any]) -> Component[P]:
|
|
89
192
|
return Component(fn, name)
|
|
90
193
|
|
|
@@ -130,4 +233,5 @@ __all__ = [
|
|
|
130
233
|
"Primitive",
|
|
131
234
|
"VDOMNode",
|
|
132
235
|
"component",
|
|
236
|
+
"is_component_code",
|
|
133
237
|
]
|
pulse/components/for_.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
from collections.abc import Callable, Iterable
|
|
2
7
|
from inspect import Parameter, signature
|
|
3
8
|
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
@@ -27,11 +32,32 @@ def For(items: Iterable[T], fn: Callable[[T, int], Element]) -> list[Element]: .
|
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
def For(items: Iterable[T], fn: Callable[..., Element]) -> list[Element]:
|
|
30
|
-
"""Map items to elements,
|
|
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)))
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Array.map. If `fn` declares `*args`, it will receive `(item, index)`.
|
|
59
|
+
Note:
|
|
60
|
+
In transpiled `@javascript` code, `For` compiles to `.map()`.
|
|
35
61
|
"""
|
|
36
62
|
try:
|
|
37
63
|
sig = signature(fn)
|
pulse/components/if_.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
"""Conditional rendering component.
|
|
2
|
+
|
|
3
|
+
Provides a declarative way to conditionally render elements based on a condition.
|
|
4
|
+
"""
|
|
5
|
+
|
|
1
6
|
from collections.abc import Iterable
|
|
2
7
|
from typing import Any, TypeVar
|
|
3
8
|
|
|
@@ -37,15 +42,33 @@ def If(
|
|
|
37
42
|
then: T1,
|
|
38
43
|
else_: T2 = None,
|
|
39
44
|
) -> T1 | T2:
|
|
40
|
-
"""Conditional rendering helper
|
|
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.
|
|
41
49
|
|
|
42
50
|
Args:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
46
55
|
|
|
47
56
|
Returns:
|
|
48
|
-
|
|
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"))
|
|
49
72
|
"""
|
|
50
73
|
# Unwrap reactive condition if needed and coerce to bool explicitly with guards
|
|
51
74
|
if isinstance(condition, (Signal, Computed)):
|
pulse/components/react_router.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
"""React Router components for client-side navigation.
|
|
2
|
+
|
|
3
|
+
Provides Pulse bindings for react-router's Link and Outlet components.
|
|
4
|
+
"""
|
|
5
|
+
|
|
1
6
|
from typing import Literal, TypedDict, Unpack
|
|
2
7
|
|
|
3
8
|
from pulse.dom.props import HTMLAnchorProps
|
|
9
|
+
from pulse.react_component import react_component
|
|
4
10
|
from pulse.transpiler import Import
|
|
5
11
|
from pulse.transpiler.nodes import Node
|
|
6
|
-
from pulse.transpiler.react_component import react_component
|
|
7
12
|
|
|
8
13
|
|
|
9
14
|
class LinkPath(TypedDict):
|
|
15
|
+
"""TypedDict for Link's `to` prop when using an object instead of string."""
|
|
16
|
+
|
|
10
17
|
pathname: str
|
|
11
18
|
search: str
|
|
12
19
|
hash: str
|
|
@@ -27,12 +34,63 @@ def Link(
|
|
|
27
34
|
state: dict[str, object] | None = None,
|
|
28
35
|
viewTransition: bool | None = None,
|
|
29
36
|
**props: Unpack[HTMLAnchorProps],
|
|
30
|
-
):
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Client-side navigation link using react-router.
|
|
39
|
+
|
|
40
|
+
Renders an anchor tag that performs client-side navigation without a full
|
|
41
|
+
page reload. Supports prefetching and various navigation behaviors.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
*children: Content to render inside the link.
|
|
45
|
+
key: React reconciliation key.
|
|
46
|
+
to: The target URL path (e.g., "/dashboard", "/users/123").
|
|
47
|
+
discover: Route discovery behavior. "render" discovers on render,
|
|
48
|
+
"none" disables discovery.
|
|
49
|
+
prefetch: Prefetch strategy. "intent" (default) prefetches on hover/focus,
|
|
50
|
+
"render" prefetches immediately, "viewport" when visible, "none" disables.
|
|
51
|
+
preventScrollReset: If True, prevents scroll position reset on navigation.
|
|
52
|
+
relative: Path resolution mode. "route" resolves relative to route hierarchy,
|
|
53
|
+
"path" resolves relative to URL path.
|
|
54
|
+
reloadDocument: If True, performs a full page navigation instead of SPA.
|
|
55
|
+
replace: If True, replaces current history entry instead of pushing.
|
|
56
|
+
state: Arbitrary state to pass to the destination location.
|
|
57
|
+
viewTransition: If True, enables View Transitions API for the navigation.
|
|
58
|
+
**props: Additional HTML anchor attributes (className, onClick, etc.).
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
Basic navigation::
|
|
62
|
+
|
|
63
|
+
ps.Link(to="/dashboard")["Go to Dashboard"]
|
|
64
|
+
|
|
65
|
+
With prefetching disabled::
|
|
66
|
+
|
|
67
|
+
ps.Link(to="/settings", prefetch="none")["Settings"]
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
31
70
|
|
|
32
71
|
|
|
33
72
|
# @react_component(Import("Outlet", "react-router", version="^7"))
|
|
34
73
|
@react_component(Import("Outlet", "react-router"))
|
|
35
|
-
def Outlet(key: str | None = None):
|
|
74
|
+
def Outlet(key: str | None = None) -> None:
|
|
75
|
+
"""Renders the matched child route's element.
|
|
76
|
+
|
|
77
|
+
Outlet is used in parent route components to render their child routes.
|
|
78
|
+
It acts as a placeholder where nested route content will be displayed.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
key: React reconciliation key.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
Layout with outlet for child routes::
|
|
85
|
+
|
|
86
|
+
@ps.component
|
|
87
|
+
def Layout():
|
|
88
|
+
return ps.div(
|
|
89
|
+
ps.nav("Navigation"),
|
|
90
|
+
ps.Outlet(), # Child route renders here
|
|
91
|
+
)
|
|
92
|
+
"""
|
|
93
|
+
...
|
|
36
94
|
|
|
37
95
|
|
|
38
96
|
__all__ = ["Link", "Outlet"]
|
pulse/context.py
CHANGED
|
@@ -16,9 +16,23 @@ if TYPE_CHECKING:
|
|
|
16
16
|
class PulseContext:
|
|
17
17
|
"""Composite context accessible to hooks and internals.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
```
|
|
22
36
|
"""
|
|
23
37
|
|
|
24
38
|
app: "App"
|
|
@@ -28,7 +42,15 @@ class PulseContext:
|
|
|
28
42
|
_token: "Token[PulseContext | None] | None" = None
|
|
29
43
|
|
|
30
44
|
@classmethod
|
|
31
|
-
def get(cls):
|
|
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
|
+
"""
|
|
32
54
|
ctx = PULSE_CONTEXT.get()
|
|
33
55
|
if ctx is None:
|
|
34
56
|
raise RuntimeError("Internal error: PULSE_CONTEXT is not set")
|
|
@@ -40,7 +62,19 @@ class PulseContext:
|
|
|
40
62
|
session: "UserSession | None" = None,
|
|
41
63
|
render: "RenderSession | None" = None,
|
|
42
64
|
route: "RouteContext | None" = None,
|
|
43
|
-
):
|
|
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
|
+
"""
|
|
44
78
|
ctx = cls.get()
|
|
45
79
|
return PulseContext(
|
|
46
80
|
app=ctx.app,
|
pulse/cookies.py
CHANGED
|
@@ -14,6 +14,26 @@ if TYPE_CHECKING:
|
|
|
14
14
|
|
|
15
15
|
@dataclass
|
|
16
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
|
+
|
|
17
37
|
name: str
|
|
18
38
|
_: KW_ONLY
|
|
19
39
|
domain: str | None = None
|
|
@@ -22,18 +42,45 @@ class Cookie:
|
|
|
22
42
|
max_age_seconds: int = 7 * 24 * 3600
|
|
23
43
|
|
|
24
44
|
def get_from_fastapi(self, request: Request) -> str | None:
|
|
25
|
-
"""Extract
|
|
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
|
+
"""
|
|
26
55
|
header = request.headers.get("cookie")
|
|
27
56
|
cookies = parse_cookie_header(header)
|
|
28
57
|
return cookies.get(self.name)
|
|
29
58
|
|
|
30
59
|
def get_from_socketio(self, environ: dict[str, Any]) -> str | None:
|
|
31
|
-
"""Extract
|
|
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
|
+
"""
|
|
32
68
|
raw = environ.get("HTTP_COOKIE") or environ.get("COOKIE")
|
|
33
69
|
cookies = parse_cookie_header(raw)
|
|
34
70
|
return cookies.get(self.name)
|
|
35
71
|
|
|
36
|
-
async def set_through_api(self, value: str):
|
|
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
|
+
"""
|
|
37
84
|
if self.secure is None:
|
|
38
85
|
raise RuntimeError(
|
|
39
86
|
"Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
|
|
@@ -48,7 +95,17 @@ class Cookie:
|
|
|
48
95
|
)
|
|
49
96
|
|
|
50
97
|
def set_on_fastapi(self, response: Response, value: str) -> None:
|
|
51
|
-
"""Set the
|
|
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
|
+
"""
|
|
52
109
|
if self.secure is None:
|
|
53
110
|
raise RuntimeError(
|
|
54
111
|
"Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
|
|
@@ -67,10 +124,31 @@ class Cookie:
|
|
|
67
124
|
|
|
68
125
|
@dataclass
|
|
69
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
|
+
|
|
70
136
|
value: str
|
|
71
137
|
|
|
72
138
|
@classmethod
|
|
73
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
|
+
"""
|
|
74
152
|
if cookie.secure is None:
|
|
75
153
|
raise RuntimeError(
|
|
76
154
|
"Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
|
|
@@ -111,6 +189,18 @@ def session_cookie(
|
|
|
111
189
|
|
|
112
190
|
|
|
113
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
|
+
|
|
114
204
|
allow_origins: Sequence[str]
|
|
115
205
|
"List of allowed origins. Use ['*'] to allow all origins. Default: ()"
|
|
116
206
|
|
|
@@ -207,6 +297,20 @@ def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
|
|
|
207
297
|
|
|
208
298
|
|
|
209
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
|
+
"""
|
|
210
314
|
cookies: dict[str, str] = {}
|
|
211
315
|
if not header:
|
|
212
316
|
return cookies
|