shadlet 0.1.1__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 (73) hide show
  1. shadlet/__init__.py +260 -0
  2. shadlet/_json.py +108 -0
  3. shadlet/app/__init__.py +4 -0
  4. shadlet/app/app.py +225 -0
  5. shadlet/app/context.py +90 -0
  6. shadlet/assets/banner.svg +102 -0
  7. shadlet/assets/logo-32.png +0 -0
  8. shadlet/assets/logo-64.png +0 -0
  9. shadlet/assets/logo.png +0 -0
  10. shadlet/assets/logo.svg +4 -0
  11. shadlet/assets/shadlet-icon.svg +4 -0
  12. shadlet/cli/__init__.py +0 -0
  13. shadlet/cli/main.py +219 -0
  14. shadlet/components/__init__.py +199 -0
  15. shadlet/components/base.py +138 -0
  16. shadlet/components/display.py +701 -0
  17. shadlet/components/enums.py +180 -0
  18. shadlet/components/forms.py +527 -0
  19. shadlet/components/navigation.py +252 -0
  20. shadlet/components/options.py +108 -0
  21. shadlet/components/overlays.py +419 -0
  22. shadlet/components/typography.py +206 -0
  23. shadlet/events/__init__.py +22 -0
  24. shadlet/events/registry.py +62 -0
  25. shadlet/events/types.py +80 -0
  26. shadlet/layout/__init__.py +35 -0
  27. shadlet/layout/layout.py +395 -0
  28. shadlet/layout/values.py +108 -0
  29. shadlet/protocol/__init__.py +53 -0
  30. shadlet/protocol/models.py +214 -0
  31. shadlet/protocol/version.py +45 -0
  32. shadlet/runtime/__init__.py +13 -0
  33. shadlet/runtime/diff.py +51 -0
  34. shadlet/runtime/errors.py +22 -0
  35. shadlet/runtime/render.py +94 -0
  36. shadlet/runtime/session.py +375 -0
  37. shadlet/state/__init__.py +4 -0
  38. shadlet/state/state.py +60 -0
  39. shadlet/state/store.py +54 -0
  40. shadlet/templates/__init__.py +19 -0
  41. shadlet/templates/builtin/dashboard/main.py +179 -0
  42. shadlet/templates/builtin/dashboard/shadlet-template.toml +13 -0
  43. shadlet/templates/builtin/landing-page/main.py +33 -0
  44. shadlet/templates/builtin/landing-page/shadlet-template.toml +13 -0
  45. shadlet/templates/builtin/minimal/README.md +3 -0
  46. shadlet/templates/builtin/minimal/main.py +25 -0
  47. shadlet/templates/builtin/minimal/pyproject.toml +5 -0
  48. shadlet/templates/builtin/minimal/shadlet-template.toml +13 -0
  49. shadlet/templates/github.py +278 -0
  50. shadlet/web/__init__.py +0 -0
  51. shadlet/web/server.py +304 -0
  52. shadlet/web/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  53. shadlet/web/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  54. shadlet/web/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  55. shadlet/web/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  56. shadlet/web/static/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  57. shadlet/web/static/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  58. shadlet/web/static/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  59. shadlet/web/static/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  60. shadlet/web/static/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  61. shadlet/web/static/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  62. shadlet/web/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  63. shadlet/web/static/assets/index-B5FX5UAO.css +1 -0
  64. shadlet/web/static/assets/index-S8TJoQ_W.js +114 -0
  65. shadlet/web/static/favicon.png +0 -0
  66. shadlet/web/static/index.html +20 -0
  67. shadlet/web/static/logo.png +0 -0
  68. shadlet/web/static/logo.svg +4 -0
  69. shadlet-0.1.1.dist-info/METADATA +263 -0
  70. shadlet-0.1.1.dist-info/RECORD +73 -0
  71. shadlet-0.1.1.dist-info/WHEEL +4 -0
  72. shadlet-0.1.1.dist-info/entry_points.txt +2 -0
  73. shadlet-0.1.1.dist-info/licenses/LICENSE +21 -0
shadlet/__init__.py ADDED
@@ -0,0 +1,260 @@
1
+ """shadlet: Python-first web applications."""
2
+
3
+ from shadlet.app import App, Context, RouteMatch, RoutingError
4
+ from shadlet.components import (
5
+ Accordion,
6
+ AccordionItem,
7
+ Alert,
8
+ AlertDialog,
9
+ AlertVariant,
10
+ Align,
11
+ AspectRatio,
12
+ Avatar,
13
+ Badge,
14
+ BadgeVariant,
15
+ Breadcrumb,
16
+ BreadcrumbItem,
17
+ Button,
18
+ ButtonGroup,
19
+ ButtonSize,
20
+ ButtonVariant,
21
+ Calendar,
22
+ CalendarMode,
23
+ Card,
24
+ Carousel,
25
+ Chart,
26
+ ChartKind,
27
+ ChartSeries,
28
+ Checkbox,
29
+ Code,
30
+ Collapsible,
31
+ Combobox,
32
+ Command,
33
+ Component,
34
+ ComponentError,
35
+ ContextMenu,
36
+ DataTable,
37
+ DatePicker,
38
+ Dialog,
39
+ Direction,
40
+ Drawer,
41
+ DropdownMenu,
42
+ Empty,
43
+ Field,
44
+ Form,
45
+ Heading,
46
+ HeadingLevel,
47
+ HoverCard,
48
+ Icon,
49
+ Input,
50
+ InputGroup,
51
+ InputOTP,
52
+ Item,
53
+ Justify,
54
+ Kbd,
55
+ Label,
56
+ Link,
57
+ Menubar,
58
+ MenuGroup,
59
+ MenuItem,
60
+ NativeSelect,
61
+ NavigationItem,
62
+ NavigationMenu,
63
+ Orientation,
64
+ Overflow,
65
+ Pagination,
66
+ Popover,
67
+ Position,
68
+ Progress,
69
+ RadioGroup,
70
+ Resizable,
71
+ Select,
72
+ SelectAlign,
73
+ SelectOption,
74
+ Separator,
75
+ Sheet,
76
+ Side,
77
+ Sidebar,
78
+ SidebarItem,
79
+ SidebarSection,
80
+ Skeleton,
81
+ Slider,
82
+ Spinner,
83
+ Switch,
84
+ TabItem,
85
+ Table,
86
+ TableColumn,
87
+ Tabs,
88
+ Text,
89
+ Textarea,
90
+ TextVariant,
91
+ Theme,
92
+ ThemeToggle,
93
+ ToastLevel,
94
+ Toggle,
95
+ ToggleGroup,
96
+ ToggleSize,
97
+ ToggleVariant,
98
+ Tooltip,
99
+ WrapMode,
100
+ )
101
+ from shadlet.components import (
102
+ Textarea as TextArea,
103
+ )
104
+ from shadlet.events import (
105
+ ChangeEvent,
106
+ ClickEvent,
107
+ Event,
108
+ NavigateEvent,
109
+ OpenChangeEvent,
110
+ SelectEvent,
111
+ SubmitEvent,
112
+ )
113
+ from shadlet.layout import (
114
+ Box,
115
+ Center,
116
+ Column,
117
+ Container,
118
+ Expanded,
119
+ Grid,
120
+ GridItem,
121
+ Positioned,
122
+ Responsive,
123
+ Row,
124
+ ScrollArea,
125
+ SizedBox,
126
+ Spacer,
127
+ Stack,
128
+ Wrap,
129
+ )
130
+ from shadlet.protocol import PROTOCOL_VERSION
131
+ from shadlet.state import State
132
+
133
+ __version__ = "0.1.1"
134
+
135
+ __all__ = [
136
+ "PROTOCOL_VERSION",
137
+ "Accordion",
138
+ "AccordionItem",
139
+ "Alert",
140
+ "AlertDialog",
141
+ "AlertVariant",
142
+ "Align",
143
+ "App",
144
+ "AspectRatio",
145
+ "Avatar",
146
+ "Badge",
147
+ "BadgeVariant",
148
+ "Box",
149
+ "Breadcrumb",
150
+ "BreadcrumbItem",
151
+ "Button",
152
+ "ButtonGroup",
153
+ "ButtonSize",
154
+ "ButtonVariant",
155
+ "Calendar",
156
+ "CalendarMode",
157
+ "Card",
158
+ "Carousel",
159
+ "Center",
160
+ "ChangeEvent",
161
+ "Chart",
162
+ "ChartKind",
163
+ "ChartSeries",
164
+ "Checkbox",
165
+ "ClickEvent",
166
+ "Code",
167
+ "Collapsible",
168
+ "Column",
169
+ "Combobox",
170
+ "Command",
171
+ "Component",
172
+ "ComponentError",
173
+ "Container",
174
+ "Context",
175
+ "ContextMenu",
176
+ "DataTable",
177
+ "DatePicker",
178
+ "Dialog",
179
+ "Direction",
180
+ "Drawer",
181
+ "DropdownMenu",
182
+ "Empty",
183
+ "Event",
184
+ "Expanded",
185
+ "Field",
186
+ "Form",
187
+ "Grid",
188
+ "GridItem",
189
+ "Heading",
190
+ "HeadingLevel",
191
+ "HoverCard",
192
+ "Icon",
193
+ "Input",
194
+ "InputGroup",
195
+ "InputOTP",
196
+ "Item",
197
+ "Justify",
198
+ "Kbd",
199
+ "Label",
200
+ "Link",
201
+ "MenuGroup",
202
+ "MenuItem",
203
+ "Menubar",
204
+ "NativeSelect",
205
+ "NavigateEvent",
206
+ "NavigationItem",
207
+ "NavigationMenu",
208
+ "OpenChangeEvent",
209
+ "Orientation",
210
+ "Overflow",
211
+ "Pagination",
212
+ "Popover",
213
+ "Position",
214
+ "Positioned",
215
+ "Progress",
216
+ "RadioGroup",
217
+ "Resizable",
218
+ "Responsive",
219
+ "RouteMatch",
220
+ "RoutingError",
221
+ "Row",
222
+ "ScrollArea",
223
+ "Select",
224
+ "SelectAlign",
225
+ "SelectEvent",
226
+ "SelectOption",
227
+ "Separator",
228
+ "Sheet",
229
+ "Side",
230
+ "Sidebar",
231
+ "SidebarItem",
232
+ "SidebarSection",
233
+ "SizedBox",
234
+ "Skeleton",
235
+ "Slider",
236
+ "Spacer",
237
+ "Spinner",
238
+ "Stack",
239
+ "State",
240
+ "SubmitEvent",
241
+ "Switch",
242
+ "TabItem",
243
+ "Table",
244
+ "TableColumn",
245
+ "Tabs",
246
+ "Text",
247
+ "TextArea",
248
+ "TextVariant",
249
+ "Textarea",
250
+ "Theme",
251
+ "ThemeToggle",
252
+ "ToastLevel",
253
+ "Toggle",
254
+ "ToggleGroup",
255
+ "ToggleSize",
256
+ "ToggleVariant",
257
+ "Tooltip",
258
+ "Wrap",
259
+ "WrapMode",
260
+ ]
shadlet/_json.py ADDED
@@ -0,0 +1,108 @@
1
+ """Conversion of Python values into deterministic JSON-compatible values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import datetime as _dt
7
+ import enum
8
+ import math
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class NotSerializableError(TypeError):
17
+ """Raised when a value cannot be represented by the shadlet protocol."""
18
+
19
+
20
+ def to_wire_value(
21
+ value: Any, *, path: str = "value", _seen: set[int] | None = None
22
+ ) -> Any:
23
+ """Convert ``value`` to a JSON-compatible value.
24
+
25
+ Callbacks, components, arbitrary objects and cyclic containers are rejected
26
+ with a precise path so validation errors are actionable.
27
+ """
28
+
29
+ if value is None or isinstance(value, (bool, str)):
30
+ return value
31
+ if isinstance(value, int):
32
+ return value
33
+ if isinstance(value, float):
34
+ if not math.isfinite(value):
35
+ raise NotSerializableError(f"{path}: non-finite floats are not supported")
36
+ return value
37
+ if isinstance(value, enum.Enum):
38
+ return to_wire_value(value.value, path=path, _seen=_seen)
39
+ if isinstance(value, (_dt.datetime, _dt.date, _dt.time)):
40
+ return value.isoformat()
41
+ if isinstance(value, Path):
42
+ return str(value)
43
+ if isinstance(value, BaseModel):
44
+ return to_wire_value(value.model_dump(mode="json"), path=path, _seen=_seen)
45
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
46
+ result: dict[str, Any] = {}
47
+ for field in dataclasses.fields(value):
48
+ result[field.name] = to_wire_value(
49
+ getattr(value, field.name), path=f"{path}.{field.name}", _seen=_seen
50
+ )
51
+ return result
52
+
53
+ from shadlet.state.state import State
54
+
55
+ if isinstance(value, State):
56
+ return to_wire_value(value.value, path=path, _seen=_seen)
57
+
58
+ if _seen is None:
59
+ _seen = set()
60
+
61
+ if isinstance(value, Mapping):
62
+ marker = id(value)
63
+ if marker in _seen:
64
+ raise NotSerializableError(f"{path}: cyclic mappings are not supported")
65
+ _seen.add(marker)
66
+ try:
67
+ mapping_result: dict[str, Any] = {}
68
+ for key, item in value.items():
69
+ if not isinstance(key, str):
70
+ raise NotSerializableError(f"{path}: mapping keys must be strings")
71
+ mapping_result[key] = to_wire_value(
72
+ item, path=f"{path}.{key}", _seen=_seen
73
+ )
74
+ return mapping_result
75
+ finally:
76
+ _seen.discard(marker)
77
+
78
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
79
+ marker = id(value)
80
+ if marker in _seen:
81
+ raise NotSerializableError(f"{path}: cyclic sequences are not supported")
82
+ _seen.add(marker)
83
+ try:
84
+ return [
85
+ to_wire_value(item, path=f"{path}[{index}]", _seen=_seen)
86
+ for index, item in enumerate(value)
87
+ ]
88
+ finally:
89
+ _seen.discard(marker)
90
+
91
+ if isinstance(value, (set, frozenset)):
92
+ raise NotSerializableError(
93
+ f"{path}: sets are not supported because their order is not deterministic"
94
+ )
95
+ if callable(value):
96
+ raise NotSerializableError(
97
+ f"{path}: callbacks must be passed to an on_* argument, not as data"
98
+ )
99
+
100
+ from shadlet.components.base import Component
101
+
102
+ if isinstance(value, Component):
103
+ raise NotSerializableError(
104
+ f"{path}: components cannot be used as props; use a named slot or children"
105
+ )
106
+ raise NotSerializableError(
107
+ f"{path}: unsupported value of type {type(value).__name__}"
108
+ )
@@ -0,0 +1,4 @@
1
+ from shadlet.app.app import App, PageFunction, Route, RoutingError
2
+ from shadlet.app.context import Context, RouteMatch
3
+
4
+ __all__ = ["App", "Context", "PageFunction", "Route", "RouteMatch", "RoutingError"]
shadlet/app/app.py ADDED
@@ -0,0 +1,225 @@
1
+ """Application lifecycle, routing and page registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from urllib.parse import unquote
10
+
11
+ from shadlet.app.context import Context, RouteMatch
12
+ from shadlet.components.base import Component
13
+ from shadlet.components.enums import Theme
14
+ from shadlet.protocol.models import RouteInfo
15
+
16
+ PageFunction = Callable[[Context], Component | Awaitable[Component]]
17
+
18
+
19
+ class RoutingError(ValueError):
20
+ """Invalid route declaration or navigation."""
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Route:
25
+ pattern: str
26
+ title: str
27
+ function: PageFunction
28
+ regex: re.Pattern[str]
29
+ static_segments: int
30
+ param_names: tuple[str, ...]
31
+
32
+
33
+ def _normalise_path(path: str) -> str:
34
+ if not isinstance(path, str) or not path:
35
+ raise RoutingError("route path must be a non-empty string")
36
+ if not path.startswith("/"):
37
+ raise RoutingError(f"route path must start with '/': {path!r}")
38
+ if "?" in path or "#" in path or " " in path:
39
+ raise RoutingError(
40
+ f"route path cannot contain queries, fragments or spaces: {path!r}"
41
+ )
42
+ if path != "/" and path.endswith("/"):
43
+ path = path.rstrip("/") or "/"
44
+ if "//" in path:
45
+ raise RoutingError(f"route path cannot contain empty segments: {path!r}")
46
+ return path
47
+
48
+
49
+ def _compile_route(path: str) -> tuple[re.Pattern[str], int, tuple[str, ...]]:
50
+ path = _normalise_path(path)
51
+ if path == "/":
52
+ return re.compile(r"^/$"), 0, ()
53
+ segments = path.strip("/").split("/")
54
+ pattern_parts: list[str] = []
55
+ names: list[str] = []
56
+ static = 0
57
+ for segment in segments:
58
+ match = re.fullmatch(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", segment)
59
+ if match:
60
+ name = match.group(1)
61
+ if name in names:
62
+ raise RoutingError(f"duplicate route parameter {name!r} in {path!r}")
63
+ names.append(name)
64
+ pattern_parts.append(f"(?P<{name}>[^/]+)")
65
+ else:
66
+ if "{" in segment or "}" in segment:
67
+ raise RoutingError(f"invalid route segment {segment!r} in {path!r}")
68
+ static += 1
69
+ pattern_parts.append(re.escape(segment))
70
+ return re.compile("^/" + "/".join(pattern_parts) + "$"), static, tuple(names)
71
+
72
+
73
+ class App:
74
+ """A shadlet application.
75
+
76
+ Pages are registered with :meth:`page`. An application instance is served
77
+ by the web runtime.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ title: str = "shadlet app",
83
+ *,
84
+ icon: str | None = None,
85
+ theme: Theme | str = Theme.LIGHT,
86
+ debug: bool = False,
87
+ version: str = "0.1.1",
88
+ toaster: bool = True,
89
+ ) -> None:
90
+ if not isinstance(title, str) or not title.strip():
91
+ raise ValueError("app title must be a non-empty string")
92
+ if theme not in tuple(Theme) and theme not in {"light", "dark", "system"}:
93
+ raise ValueError("theme must be 'light', 'dark' or 'system'")
94
+ self.title = title
95
+ self.icon = icon
96
+ self.theme = str(theme)
97
+ self.debug = bool(debug)
98
+ self.version = version
99
+ self.toaster = bool(toaster)
100
+ self._routes: list[Route] = []
101
+ self._not_found: PageFunction | None = None
102
+
103
+ def page(
104
+ self,
105
+ path: str,
106
+ *,
107
+ title: str | None = None,
108
+ ) -> Callable[[PageFunction], PageFunction]:
109
+ normalised = _normalise_path(path)
110
+ regex, static_segments, names = _compile_route(normalised)
111
+
112
+ def decorator(function: PageFunction) -> PageFunction:
113
+ if not callable(function):
114
+ raise RoutingError("page must be a callable")
115
+ for route in self._routes:
116
+ if route.pattern == normalised:
117
+ raise RoutingError(f"duplicate route {normalised!r}")
118
+ if route.regex.pattern == regex.pattern:
119
+ raise RoutingError(
120
+ f"route {normalised!r} conflicts with {route.pattern!r}"
121
+ )
122
+ page_title = title or function.__name__.replace("_", " ").title()
123
+ self._routes.append(
124
+ Route(
125
+ pattern=normalised,
126
+ title=page_title,
127
+ function=function,
128
+ regex=regex,
129
+ static_segments=static_segments,
130
+ param_names=names,
131
+ )
132
+ )
133
+ return function
134
+
135
+ return decorator
136
+
137
+ def not_found(self, function: PageFunction) -> PageFunction:
138
+ if self._not_found is not None:
139
+ raise RoutingError("a not-found page is already registered")
140
+ if not callable(function):
141
+ raise RoutingError("not-found page must be a callable")
142
+ self._not_found = function
143
+ return function
144
+
145
+ def routes(self) -> list[RouteInfo]:
146
+ return [
147
+ RouteInfo(path=route.pattern, title=route.title) for route in self._routes
148
+ ]
149
+
150
+ def match(self, path: str) -> RouteMatch:
151
+ requested = _normalise_path(path)
152
+ for route in sorted(
153
+ self._routes, key=lambda item: item.static_segments, reverse=True
154
+ ):
155
+ match = route.regex.fullmatch(requested)
156
+ if match:
157
+ params = {
158
+ key: unquote(value) for key, value in match.groupdict().items()
159
+ }
160
+ return RouteMatch(
161
+ path=requested,
162
+ pattern=route.pattern,
163
+ title=route.title,
164
+ params=params,
165
+ )
166
+ return RouteMatch(
167
+ path=requested,
168
+ pattern=requested,
169
+ title="not found",
170
+ not_found=True,
171
+ )
172
+
173
+ def _function_for(self, match: RouteMatch) -> PageFunction:
174
+ if match.not_found:
175
+ if self._not_found is not None:
176
+ return self._not_found
177
+ return _default_not_found
178
+ for route in self._routes:
179
+ if route.pattern == match.pattern:
180
+ return route.function
181
+ raise RoutingError(f"no page registered for {match.pattern!r}")
182
+
183
+ async def render_page(self, match: RouteMatch, context: Context) -> Component:
184
+ function = self._function_for(match)
185
+ result = function(context)
186
+ if inspect.isawaitable(result):
187
+ result = await result
188
+ if not isinstance(result, Component):
189
+ raise TypeError(
190
+ f"page {function.__name__!r} returned {type(result).__name__}; "
191
+ "expected a shadlet Component"
192
+ )
193
+ return result
194
+
195
+ def run(
196
+ self,
197
+ *,
198
+ host: str = "127.0.0.1",
199
+ port: int = 8000,
200
+ reload: bool = False,
201
+ ) -> None:
202
+ import os
203
+
204
+ from shadlet.web.server import run_app
205
+
206
+ target_port = int(os.environ.get("SHADLET_PORT", str(port)))
207
+ run_app(self, host=host, port=target_port, reload=reload)
208
+
209
+
210
+ def _default_not_found(context: Context) -> Component:
211
+ from shadlet.components.display import Card
212
+ from shadlet.components.typography import Heading, Text
213
+ from shadlet.layout.layout import Center
214
+
215
+ return Center(
216
+ Card(
217
+ Heading("page not found", level=1),
218
+ Text(f"no route matches {context.path}", variant="muted"),
219
+ ),
220
+ min_height="60vh",
221
+ padding=24,
222
+ )
223
+
224
+
225
+ __all__ = ["App", "PageFunction", "Route", "RoutingError"]
shadlet/app/context.py ADDED
@@ -0,0 +1,90 @@
1
+ """Application context passed to page functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from shadlet.components.enums import ToastLevel
10
+ from shadlet.protocol.models import ToastMessage
11
+ from shadlet.state.state import State
12
+
13
+ if TYPE_CHECKING:
14
+ from shadlet.runtime.session import Session
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class RouteMatch:
19
+ path: str
20
+ pattern: str
21
+ title: str
22
+ params: dict[str, str] = field(default_factory=dict)
23
+ not_found: bool = False
24
+
25
+
26
+ class Context:
27
+ """Per-request context with session-isolated state and navigation."""
28
+
29
+ __slots__ = ("_session", "params", "route")
30
+
31
+ def __init__(self, session: Session, route: RouteMatch) -> None:
32
+ self._session = session
33
+ self.route = route
34
+ self.params = route.params
35
+
36
+ @property
37
+ def path(self) -> str:
38
+ return self.route.path
39
+
40
+ def state(self, initial: Any, *, key: str | None = None) -> State[Any]:
41
+ """Return a session-scoped reactive state slot.
42
+
43
+ Without an explicit key the slot is keyed by the calling source
44
+ location, so moving a line of code intentionally creates a new slot.
45
+ """
46
+
47
+ if key is not None:
48
+ if not isinstance(key, str) or not key:
49
+ raise ValueError("state key must be a non-empty string")
50
+ full_key = f"{self.route.pattern}:{key}"
51
+ else:
52
+ frame = inspect.currentframe()
53
+ caller = frame.f_back if frame is not None else None
54
+ while caller is not None and caller.f_code.co_filename == __file__:
55
+ caller = caller.f_back
56
+ if caller is None:
57
+ raise RuntimeError("could not determine state call site")
58
+ full_key = (
59
+ f"{self.route.pattern}:{caller.f_code.co_filename}:{caller.f_lineno}"
60
+ )
61
+ return self._session.state_store.get(
62
+ full_key,
63
+ initial,
64
+ self._session.state_changed,
65
+ )
66
+
67
+ def toast(
68
+ self,
69
+ message: str,
70
+ *,
71
+ description: str | None = None,
72
+ level: ToastLevel | str = ToastLevel.DEFAULT,
73
+ duration: int | None = None,
74
+ ) -> None:
75
+ if not isinstance(message, str) or not message:
76
+ raise ValueError("toast message must be a non-empty string")
77
+ self._session.send_toast(
78
+ ToastMessage(
79
+ level=str(level), # type: ignore[arg-type]
80
+ message=message,
81
+ description=description,
82
+ duration=duration,
83
+ )
84
+ )
85
+
86
+ def navigate(self, path: str) -> None:
87
+ self._session.request_navigation(path)
88
+
89
+
90
+ __all__ = ["Context", "RouteMatch"]