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.
- shadlet/__init__.py +260 -0
- shadlet/_json.py +108 -0
- shadlet/app/__init__.py +4 -0
- shadlet/app/app.py +225 -0
- shadlet/app/context.py +90 -0
- shadlet/assets/banner.svg +102 -0
- shadlet/assets/logo-32.png +0 -0
- shadlet/assets/logo-64.png +0 -0
- shadlet/assets/logo.png +0 -0
- shadlet/assets/logo.svg +4 -0
- shadlet/assets/shadlet-icon.svg +4 -0
- shadlet/cli/__init__.py +0 -0
- shadlet/cli/main.py +219 -0
- shadlet/components/__init__.py +199 -0
- shadlet/components/base.py +138 -0
- shadlet/components/display.py +701 -0
- shadlet/components/enums.py +180 -0
- shadlet/components/forms.py +527 -0
- shadlet/components/navigation.py +252 -0
- shadlet/components/options.py +108 -0
- shadlet/components/overlays.py +419 -0
- shadlet/components/typography.py +206 -0
- shadlet/events/__init__.py +22 -0
- shadlet/events/registry.py +62 -0
- shadlet/events/types.py +80 -0
- shadlet/layout/__init__.py +35 -0
- shadlet/layout/layout.py +395 -0
- shadlet/layout/values.py +108 -0
- shadlet/protocol/__init__.py +53 -0
- shadlet/protocol/models.py +214 -0
- shadlet/protocol/version.py +45 -0
- shadlet/runtime/__init__.py +13 -0
- shadlet/runtime/diff.py +51 -0
- shadlet/runtime/errors.py +22 -0
- shadlet/runtime/render.py +94 -0
- shadlet/runtime/session.py +375 -0
- shadlet/state/__init__.py +4 -0
- shadlet/state/state.py +60 -0
- shadlet/state/store.py +54 -0
- shadlet/templates/__init__.py +19 -0
- shadlet/templates/builtin/dashboard/main.py +179 -0
- shadlet/templates/builtin/dashboard/shadlet-template.toml +13 -0
- shadlet/templates/builtin/landing-page/main.py +33 -0
- shadlet/templates/builtin/landing-page/shadlet-template.toml +13 -0
- shadlet/templates/builtin/minimal/README.md +3 -0
- shadlet/templates/builtin/minimal/main.py +25 -0
- shadlet/templates/builtin/minimal/pyproject.toml +5 -0
- shadlet/templates/builtin/minimal/shadlet-template.toml +13 -0
- shadlet/templates/github.py +278 -0
- shadlet/web/__init__.py +0 -0
- shadlet/web/server.py +304 -0
- shadlet/web/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- shadlet/web/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- shadlet/web/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- shadlet/web/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- shadlet/web/static/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- shadlet/web/static/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- shadlet/web/static/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- shadlet/web/static/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- shadlet/web/static/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- shadlet/web/static/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- shadlet/web/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- shadlet/web/static/assets/index-B5FX5UAO.css +1 -0
- shadlet/web/static/assets/index-S8TJoQ_W.js +114 -0
- shadlet/web/static/favicon.png +0 -0
- shadlet/web/static/index.html +20 -0
- shadlet/web/static/logo.png +0 -0
- shadlet/web/static/logo.svg +4 -0
- shadlet-0.1.1.dist-info/METADATA +263 -0
- shadlet-0.1.1.dist-info/RECORD +73 -0
- shadlet-0.1.1.dist-info/WHEEL +4 -0
- shadlet-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
shadlet/app/__init__.py
ADDED
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"]
|