edron 0.1.0__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.
- edron/__init__.py +62 -0
- edron/_internal.py +68 -0
- edron/app.py +506 -0
- edron/cache.py +62 -0
- edron/capabilities.py +52 -0
- edron/cli/__init__.py +3 -0
- edron/cli/main.py +30 -0
- edron/confirm.py +10 -0
- edron/dependencies.py +36 -0
- edron/descriptors.py +217 -0
- edron/downloads.py +14 -0
- edron/errors.py +27 -0
- edron/jobs.py +52 -0
- edron/outcomes.py +35 -0
- edron/page.py +670 -0
- edron/py.typed +0 -0
- edron/styling.py +35 -0
- edron-0.1.0.dist-info/METADATA +71 -0
- edron-0.1.0.dist-info/RECORD +22 -0
- edron-0.1.0.dist-info/WHEEL +4 -0
- edron-0.1.0.dist-info/entry_points.txt +2 -0
- edron-0.1.0.dist-info/licenses/LICENSE +21 -0
edron/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Edron: a class-oriented authoring facade for Hedron."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from edron.app import App
|
|
6
|
+
from edron.cache import CachedFunction, cache_data
|
|
7
|
+
from edron.capabilities import (
|
|
8
|
+
BrokenCapabilityError,
|
|
9
|
+
CapabilityError,
|
|
10
|
+
IncompatibleCapabilityError,
|
|
11
|
+
MissingCapabilityError,
|
|
12
|
+
)
|
|
13
|
+
from edron.confirm import Confirm
|
|
14
|
+
from edron.dependencies import Dependency, dependency
|
|
15
|
+
from edron.descriptors import Action, BoundAction, BoundFragment, Fragment, action, fragment
|
|
16
|
+
from edron.downloads import Download, download
|
|
17
|
+
from edron.errors import BindingError, EdronError, PhaseError, RegistrationError
|
|
18
|
+
from edron.jobs import JobBackend, JobFlow, JobScope # pyright: ignore[reportUnknownVariableType]
|
|
19
|
+
from edron.outcomes import Outcome, refresh, success
|
|
20
|
+
from edron.page import Container, FilterScope, Page
|
|
21
|
+
from edron.styling import Color, DesignSystem, StyleContext, StyleRecipe, Theme, ThemeSpec, theme
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Action",
|
|
25
|
+
"App",
|
|
26
|
+
"BindingError",
|
|
27
|
+
"BoundAction",
|
|
28
|
+
"BoundFragment",
|
|
29
|
+
"CachedFunction",
|
|
30
|
+
"CapabilityError",
|
|
31
|
+
"Color",
|
|
32
|
+
"Confirm",
|
|
33
|
+
"Container",
|
|
34
|
+
"Dependency",
|
|
35
|
+
"DesignSystem",
|
|
36
|
+
"Download",
|
|
37
|
+
"EdronError",
|
|
38
|
+
"FilterScope",
|
|
39
|
+
"Fragment",
|
|
40
|
+
"IncompatibleCapabilityError",
|
|
41
|
+
"JobBackend",
|
|
42
|
+
"JobFlow",
|
|
43
|
+
"JobScope",
|
|
44
|
+
"MissingCapabilityError",
|
|
45
|
+
"BrokenCapabilityError",
|
|
46
|
+
"Outcome",
|
|
47
|
+
"Page",
|
|
48
|
+
"PhaseError",
|
|
49
|
+
"RegistrationError",
|
|
50
|
+
"StyleContext",
|
|
51
|
+
"StyleRecipe",
|
|
52
|
+
"Theme",
|
|
53
|
+
"ThemeSpec",
|
|
54
|
+
"action",
|
|
55
|
+
"cache_data",
|
|
56
|
+
"dependency",
|
|
57
|
+
"download",
|
|
58
|
+
"fragment",
|
|
59
|
+
"refresh",
|
|
60
|
+
"success",
|
|
61
|
+
"theme",
|
|
62
|
+
]
|
edron/_internal.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from edron.errors import PhaseError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Buffer:
|
|
14
|
+
entries: list[Any] = field(default_factory=list)
|
|
15
|
+
closed: bool = False
|
|
16
|
+
|
|
17
|
+
def append(self, value: Any) -> None:
|
|
18
|
+
if self.closed:
|
|
19
|
+
raise PhaseError(
|
|
20
|
+
"output was emitted after the request phase closed", code="EDRON_LATE_OUTPUT"
|
|
21
|
+
)
|
|
22
|
+
self.entries.append(value)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Frame:
|
|
27
|
+
app: Any
|
|
28
|
+
page: Any
|
|
29
|
+
phase: str
|
|
30
|
+
request: Any = None
|
|
31
|
+
buffer: Buffer = field(default_factory=Buffer)
|
|
32
|
+
parent: Frame | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_current_frame: ContextVar[Frame | None] = ContextVar("edron_current_frame", default=None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current_frame() -> Frame | None:
|
|
39
|
+
return _current_frame.get()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def require_frame(*phases: str) -> Frame:
|
|
43
|
+
frame = current_frame()
|
|
44
|
+
if frame is None:
|
|
45
|
+
raise PhaseError(
|
|
46
|
+
"this Edron operation is only valid during a request", code="EDRON_NO_REQUEST"
|
|
47
|
+
)
|
|
48
|
+
if phases and frame.phase not in phases:
|
|
49
|
+
expected = ", ".join(phases)
|
|
50
|
+
raise PhaseError(
|
|
51
|
+
f"operation is valid during {expected}, not {frame.phase}",
|
|
52
|
+
code="EDRON_WRONG_PHASE",
|
|
53
|
+
)
|
|
54
|
+
return frame
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def frame_context(frame: Frame) -> Iterator[Frame]:
|
|
59
|
+
token = _current_frame.set(frame)
|
|
60
|
+
try:
|
|
61
|
+
yield frame
|
|
62
|
+
finally:
|
|
63
|
+
frame.buffer.closed = True
|
|
64
|
+
_current_frame.reset(token)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def native_request() -> Any:
|
|
68
|
+
return require_frame().request
|
edron/app.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any, get_args, get_origin
|
|
9
|
+
from urllib.parse import quote, urlencode, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
import hedron
|
|
14
|
+
from edron._internal import Frame, frame_context
|
|
15
|
+
from edron.dependencies import Dependency
|
|
16
|
+
from edron.descriptors import Action, BoundAction, Fragment
|
|
17
|
+
from edron.errors import BindingError, RegistrationError
|
|
18
|
+
from edron.page import Page
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class App:
|
|
22
|
+
"""Class-oriented facade over exactly one native :class:`hedron.Hedron` app."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
title: str,
|
|
28
|
+
theme: Any = None,
|
|
29
|
+
security: Any = "standard",
|
|
30
|
+
session_secret: str | None = None,
|
|
31
|
+
production: bool | None = None,
|
|
32
|
+
build_dir: str | Path | None = None,
|
|
33
|
+
root_path: str | None = None,
|
|
34
|
+
debug: bool = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
if not isinstance(title, str) or not title.strip():
|
|
37
|
+
raise RegistrationError("App.title must be a non-empty string", code="EDRON_APP_TITLE")
|
|
38
|
+
kwargs: dict[str, Any] = {
|
|
39
|
+
"title": title,
|
|
40
|
+
"security": security,
|
|
41
|
+
"production": production,
|
|
42
|
+
"build_dir": build_dir,
|
|
43
|
+
"root_path": root_path,
|
|
44
|
+
"debug": debug,
|
|
45
|
+
}
|
|
46
|
+
if theme is not None:
|
|
47
|
+
kwargs["theme"] = theme
|
|
48
|
+
if session_secret is not None:
|
|
49
|
+
kwargs["session_secret"] = session_secret
|
|
50
|
+
self.title = title
|
|
51
|
+
self.hedron = hedron.Hedron(**kwargs)
|
|
52
|
+
self._pages: dict[str, Any] = {}
|
|
53
|
+
self._fragments: dict[int, Any] = {}
|
|
54
|
+
self._actions: dict[int, Any] = {}
|
|
55
|
+
self._sealed = False
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_hedron(cls, app: hedron.Hedron, *, title: str | None = None) -> App:
|
|
59
|
+
if not isinstance(app, hedron.Hedron):
|
|
60
|
+
raise TypeError("from_hedron expects a Hedron instance")
|
|
61
|
+
instance = cls.__new__(cls)
|
|
62
|
+
instance.title = title or getattr(app, "title", "Edron application")
|
|
63
|
+
instance.hedron = app
|
|
64
|
+
instance._pages = {}
|
|
65
|
+
instance._fragments = {}
|
|
66
|
+
instance._actions = {}
|
|
67
|
+
instance._sealed = False
|
|
68
|
+
return instance
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def routes(self) -> Any:
|
|
72
|
+
return self.hedron.routes
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def native(self) -> hedron.Hedron:
|
|
76
|
+
return self.hedron
|
|
77
|
+
|
|
78
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
79
|
+
"""Expose the facade as an ordinary ASGI application."""
|
|
80
|
+
await self.hedron(scope, receive, send)
|
|
81
|
+
|
|
82
|
+
def _ensure_open(self) -> None:
|
|
83
|
+
if self._sealed:
|
|
84
|
+
raise RegistrationError("the Edron app is sealed", code="EDRON_APP_SEALED")
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _unwrap_annotations(annotation: Any) -> Any:
|
|
88
|
+
if get_origin(annotation) is Annotated:
|
|
89
|
+
return get_args(annotation)[0]
|
|
90
|
+
return annotation
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _dependency_signature(
|
|
94
|
+
signature: inspect.Signature, dependencies: Sequence[Any]
|
|
95
|
+
) -> inspect.Signature:
|
|
96
|
+
params = list(signature.parameters.values())
|
|
97
|
+
params.extend(
|
|
98
|
+
inspect.Parameter(
|
|
99
|
+
f"__edron_dep_{index}",
|
|
100
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
101
|
+
annotation=Any,
|
|
102
|
+
default=dependency.native() if isinstance(dependency, Dependency) else dependency,
|
|
103
|
+
)
|
|
104
|
+
for index, dependency in enumerate(dependencies)
|
|
105
|
+
)
|
|
106
|
+
return signature.replace(parameters=params)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _without_self(signature: inspect.Signature) -> inspect.Signature:
|
|
110
|
+
params = list(signature.parameters.values())
|
|
111
|
+
if params and params[0].name == "self":
|
|
112
|
+
params.pop(0)
|
|
113
|
+
return signature.replace(parameters=params)
|
|
114
|
+
|
|
115
|
+
def _page_dependencies(self, page_type: type[Page]) -> list[Dependency[Any]]:
|
|
116
|
+
return [value for value in page_type.__dict__.values() if isinstance(value, Dependency)]
|
|
117
|
+
|
|
118
|
+
def _instantiate(self, page_type: type[Page], dependency_values: Mapping[str, Any]) -> Page:
|
|
119
|
+
try:
|
|
120
|
+
instance = page_type()
|
|
121
|
+
except TypeError as exc:
|
|
122
|
+
raise RegistrationError(
|
|
123
|
+
f"{page_type.__name__} must have a no-argument constructor", code="EDRON_PAGE_INIT"
|
|
124
|
+
) from exc
|
|
125
|
+
for name, value in dependency_values.items():
|
|
126
|
+
setattr(instance, name, value)
|
|
127
|
+
return instance
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _dependency_values(page_type: type[Page], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
values: dict[str, Any] = {}
|
|
132
|
+
for index, dependency in enumerate(
|
|
133
|
+
value for value in page_type.__dict__.values() if isinstance(value, Dependency)
|
|
134
|
+
):
|
|
135
|
+
key = f"__edron_dep_{index}"
|
|
136
|
+
if key in kwargs:
|
|
137
|
+
values[dependency.name or key] = kwargs.pop(key)
|
|
138
|
+
return values
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _call_with_kwargs(fn: Callable[..., Any], instance: Page, kwargs: dict[str, Any]) -> Any:
|
|
142
|
+
signature = inspect.signature(fn)
|
|
143
|
+
names = set(signature.parameters) - {"self"}
|
|
144
|
+
return fn(instance, **{key: value for key, value in kwargs.items() if key in names})
|
|
145
|
+
|
|
146
|
+
def page(
|
|
147
|
+
self,
|
|
148
|
+
path: str,
|
|
149
|
+
*,
|
|
150
|
+
title: str,
|
|
151
|
+
name: str | None = None,
|
|
152
|
+
show_title: bool = True,
|
|
153
|
+
dependencies: Sequence[Any] = (),
|
|
154
|
+
) -> Callable[[type[Page]], type[Page]]:
|
|
155
|
+
self._ensure_open()
|
|
156
|
+
if not path.startswith("/"):
|
|
157
|
+
raise RegistrationError("page paths must begin with /", code="EDRON_PAGE_PATH")
|
|
158
|
+
|
|
159
|
+
def register(page_type: type[Page]) -> type[Page]:
|
|
160
|
+
self._register_page(
|
|
161
|
+
page_type, path, title, name=name, show_title=show_title, dependencies=dependencies
|
|
162
|
+
)
|
|
163
|
+
return page_type
|
|
164
|
+
|
|
165
|
+
return register
|
|
166
|
+
|
|
167
|
+
def _register_page(
|
|
168
|
+
self,
|
|
169
|
+
page_type: type[Page],
|
|
170
|
+
path: str,
|
|
171
|
+
title: str,
|
|
172
|
+
*,
|
|
173
|
+
name: str | None,
|
|
174
|
+
show_title: bool,
|
|
175
|
+
dependencies: Sequence[Any],
|
|
176
|
+
) -> None:
|
|
177
|
+
self._ensure_open()
|
|
178
|
+
if not inspect.isclass(page_type) or not issubclass(page_type, Page) or page_type is Page:
|
|
179
|
+
raise RegistrationError(
|
|
180
|
+
"@app.page must decorate a Page subclass", code="EDRON_PAGE_TYPE"
|
|
181
|
+
)
|
|
182
|
+
if path in self._pages:
|
|
183
|
+
raise RegistrationError(
|
|
184
|
+
f"page path {path!r} is already registered", code="EDRON_DUPLICATE_PATH"
|
|
185
|
+
)
|
|
186
|
+
if "__init__" in page_type.__dict__:
|
|
187
|
+
raise RegistrationError("Page classes must not define __init__", code="EDRON_PAGE_INIT")
|
|
188
|
+
render = page_type.__dict__.get("render")
|
|
189
|
+
if render is None or not callable(render):
|
|
190
|
+
raise RegistrationError(
|
|
191
|
+
f"{page_type.__name__} must define render()", code="EDRON_RENDER_MISSING"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
page_dependencies = [
|
|
195
|
+
*self._page_dependencies(page_type),
|
|
196
|
+
*[item for item in dependencies if isinstance(item, Dependency)],
|
|
197
|
+
]
|
|
198
|
+
page_route_dependencies = list(dependencies)
|
|
199
|
+
render_signature = self._dependency_signature(
|
|
200
|
+
self._without_self(inspect.signature(render)), page_dependencies
|
|
201
|
+
)
|
|
202
|
+
route_name = name or page_type.__name__.lower()
|
|
203
|
+
|
|
204
|
+
def endpoint(**kwargs: Any) -> Any:
|
|
205
|
+
dependency_values = self._dependency_values(page_type, kwargs)
|
|
206
|
+
request = self._request()
|
|
207
|
+
frame = Frame(self, None, "page", request=request)
|
|
208
|
+
with frame_context(frame):
|
|
209
|
+
instance = self._instantiate(page_type, dependency_values)
|
|
210
|
+
frame.page = instance
|
|
211
|
+
result = self._call_with_kwargs(render, instance, kwargs)
|
|
212
|
+
if inspect.isawaitable(result):
|
|
213
|
+
raise RuntimeError("async render requires the async route adapter")
|
|
214
|
+
if result is not None:
|
|
215
|
+
instance.include(result)
|
|
216
|
+
return hedron_core_page(
|
|
217
|
+
*instance._resolved_output(), title=title if show_title else None
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
endpoint.__name__ = f"edron_{route_name}"
|
|
221
|
+
endpoint.__module__ = page_type.__module__
|
|
222
|
+
endpoint.__signature__ = render_signature # type: ignore[attr-defined]
|
|
223
|
+
if inspect.iscoroutinefunction(render):
|
|
224
|
+
|
|
225
|
+
async def async_endpoint(**kwargs: Any) -> Any:
|
|
226
|
+
dependency_values = self._dependency_values(page_type, kwargs)
|
|
227
|
+
frame = Frame(self, None, "page", request=self._request())
|
|
228
|
+
with frame_context(frame):
|
|
229
|
+
instance = self._instantiate(page_type, dependency_values)
|
|
230
|
+
frame.page = instance
|
|
231
|
+
result = await self._call_with_kwargs(render, instance, kwargs)
|
|
232
|
+
if result is not None:
|
|
233
|
+
instance.include(result)
|
|
234
|
+
return hedron_core_page(
|
|
235
|
+
*instance._resolved_output(), title=title if show_title else None
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async_endpoint.__name__ = endpoint.__name__
|
|
239
|
+
async_endpoint.__module__ = endpoint.__module__
|
|
240
|
+
async_endpoint.__signature__ = render_signature # type: ignore[attr-defined]
|
|
241
|
+
endpoint = async_endpoint
|
|
242
|
+
self.hedron.page(
|
|
243
|
+
path,
|
|
244
|
+
name=route_name,
|
|
245
|
+
dependencies=[self._native_dependency(x) for x in page_route_dependencies],
|
|
246
|
+
)(endpoint)
|
|
247
|
+
|
|
248
|
+
page_record = {
|
|
249
|
+
"type": page_type,
|
|
250
|
+
"path": path,
|
|
251
|
+
"title": title,
|
|
252
|
+
"name": route_name,
|
|
253
|
+
"show_title": show_title,
|
|
254
|
+
}
|
|
255
|
+
self._pages[path] = page_record
|
|
256
|
+
for member_name, member in page_type.__dict__.items():
|
|
257
|
+
if isinstance(member, Fragment):
|
|
258
|
+
self._register_fragment(
|
|
259
|
+
member,
|
|
260
|
+
page_type,
|
|
261
|
+
path,
|
|
262
|
+
member_name,
|
|
263
|
+
callable_dependencies=[
|
|
264
|
+
*page_dependencies,
|
|
265
|
+
*[item for item in member.dependencies if isinstance(item, Dependency)],
|
|
266
|
+
],
|
|
267
|
+
route_dependencies=[*page_route_dependencies, *member.dependencies],
|
|
268
|
+
)
|
|
269
|
+
elif isinstance(member, Action):
|
|
270
|
+
self._register_action(
|
|
271
|
+
member,
|
|
272
|
+
page_type,
|
|
273
|
+
path,
|
|
274
|
+
member_name,
|
|
275
|
+
callable_dependencies=[
|
|
276
|
+
*page_dependencies,
|
|
277
|
+
*[item for item in member.dependencies if isinstance(item, Dependency)],
|
|
278
|
+
],
|
|
279
|
+
route_dependencies=[*page_route_dependencies, *member.dependencies],
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _native_dependency(value: Any) -> Any:
|
|
284
|
+
return value.native() if isinstance(value, Dependency) else value
|
|
285
|
+
|
|
286
|
+
def _register_fragment(
|
|
287
|
+
self,
|
|
288
|
+
definition: Fragment[Any],
|
|
289
|
+
page_type: type[Page],
|
|
290
|
+
page_path: str,
|
|
291
|
+
member_name: str,
|
|
292
|
+
*,
|
|
293
|
+
callable_dependencies: Sequence[Any],
|
|
294
|
+
route_dependencies: Sequence[Any],
|
|
295
|
+
) -> None:
|
|
296
|
+
route = definition.path or f"{page_path.rstrip('/')}/__edron/{member_name}"
|
|
297
|
+
fn_signature = self._without_self(inspect.signature(definition.fn))
|
|
298
|
+
signature = self._dependency_signature(fn_signature, callable_dependencies)
|
|
299
|
+
|
|
300
|
+
def endpoint(**kwargs: Any) -> Any:
|
|
301
|
+
dependency_values = self._dependency_values(page_type, kwargs)
|
|
302
|
+
frame = Frame(self, None, "fragment", request=self._request())
|
|
303
|
+
with frame_context(frame):
|
|
304
|
+
instance = self._instantiate(page_type, dependency_values)
|
|
305
|
+
frame.page = instance
|
|
306
|
+
result = self._call_with_kwargs(definition.fn, instance, kwargs)
|
|
307
|
+
if inspect.isawaitable(result):
|
|
308
|
+
raise RuntimeError("async fragments require the async route adapter")
|
|
309
|
+
if result is not None:
|
|
310
|
+
instance.include(result)
|
|
311
|
+
return hedron_core_fragment(*instance._resolved_output())
|
|
312
|
+
|
|
313
|
+
endpoint.__name__ = f"edron_fragment_{page_type.__name__}_{member_name}"
|
|
314
|
+
endpoint.__module__ = page_type.__module__
|
|
315
|
+
endpoint.__signature__ = signature # type: ignore[attr-defined]
|
|
316
|
+
if inspect.iscoroutinefunction(definition.fn):
|
|
317
|
+
|
|
318
|
+
async def async_endpoint(**kwargs: Any) -> Any:
|
|
319
|
+
dependency_values = self._dependency_values(page_type, kwargs)
|
|
320
|
+
frame = Frame(self, None, "fragment", request=self._request())
|
|
321
|
+
with frame_context(frame):
|
|
322
|
+
instance = self._instantiate(page_type, dependency_values)
|
|
323
|
+
frame.page = instance
|
|
324
|
+
result = await self._call_with_kwargs(definition.fn, instance, kwargs)
|
|
325
|
+
if result is not None:
|
|
326
|
+
instance.include(result)
|
|
327
|
+
return hedron_core_fragment(*instance._resolved_output())
|
|
328
|
+
|
|
329
|
+
async_endpoint.__name__ = endpoint.__name__
|
|
330
|
+
async_endpoint.__module__ = endpoint.__module__
|
|
331
|
+
async_endpoint.__signature__ = signature # type: ignore[attr-defined]
|
|
332
|
+
endpoint = async_endpoint
|
|
333
|
+
native = self.hedron.refreshable(
|
|
334
|
+
route,
|
|
335
|
+
name=f"{page_type.__name__}_{member_name}",
|
|
336
|
+
fallback=definition.fallback,
|
|
337
|
+
dependencies=[self._native_dependency(x) for x in route_dependencies],
|
|
338
|
+
)(endpoint)
|
|
339
|
+
definition._native = native
|
|
340
|
+
self._fragments[id(definition)] = native
|
|
341
|
+
|
|
342
|
+
def _register_action(
|
|
343
|
+
self,
|
|
344
|
+
definition: Action[Any, Any],
|
|
345
|
+
page_type: type[Page],
|
|
346
|
+
page_path: str,
|
|
347
|
+
member_name: str,
|
|
348
|
+
*,
|
|
349
|
+
callable_dependencies: Sequence[Any],
|
|
350
|
+
route_dependencies: Sequence[Any],
|
|
351
|
+
) -> None:
|
|
352
|
+
route = definition.path or f"{page_path.rstrip('/')}/__edron/{member_name}"
|
|
353
|
+
signature = self._without_self(inspect.signature(definition.fn))
|
|
354
|
+
parameters = list(signature.parameters.values())
|
|
355
|
+
for index, parameter in enumerate(parameters):
|
|
356
|
+
annotation = self._unwrap_annotations(parameter.annotation)
|
|
357
|
+
if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
|
|
358
|
+
from hedron.type_authoring import FormBody
|
|
359
|
+
|
|
360
|
+
parameters[index] = parameter.replace(annotation=Annotated[annotation, FormBody()])
|
|
361
|
+
break
|
|
362
|
+
signature = self._dependency_signature(
|
|
363
|
+
signature.replace(parameters=parameters), callable_dependencies
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def endpoint(**kwargs: Any) -> Any:
|
|
367
|
+
dependency_values = self._dependency_values(page_type, kwargs)
|
|
368
|
+
frame = Frame(self, None, "action", request=self._request())
|
|
369
|
+
with frame_context(frame):
|
|
370
|
+
instance = self._instantiate(page_type, dependency_values)
|
|
371
|
+
frame.page = instance
|
|
372
|
+
return self._call_with_kwargs(definition.fn, instance, kwargs)
|
|
373
|
+
|
|
374
|
+
endpoint.__name__ = f"edron_action_{page_type.__name__}_{member_name}"
|
|
375
|
+
endpoint.__module__ = page_type.__module__
|
|
376
|
+
endpoint.__signature__ = signature # type: ignore[attr-defined]
|
|
377
|
+
if inspect.iscoroutinefunction(definition.fn):
|
|
378
|
+
|
|
379
|
+
async def async_endpoint(**kwargs: Any) -> Any:
|
|
380
|
+
dependency_values = self._dependency_values(page_type, kwargs)
|
|
381
|
+
frame = Frame(self, None, "action", request=self._request())
|
|
382
|
+
with frame_context(frame):
|
|
383
|
+
instance = self._instantiate(page_type, dependency_values)
|
|
384
|
+
frame.page = instance
|
|
385
|
+
return await self._call_with_kwargs(definition.fn, instance, kwargs)
|
|
386
|
+
|
|
387
|
+
async_endpoint.__name__ = endpoint.__name__
|
|
388
|
+
async_endpoint.__module__ = endpoint.__module__
|
|
389
|
+
async_endpoint.__signature__ = signature # type: ignore[attr-defined]
|
|
390
|
+
endpoint = async_endpoint
|
|
391
|
+
native = self.hedron.command(
|
|
392
|
+
route,
|
|
393
|
+
method=definition.method.upper(),
|
|
394
|
+
name=f"{page_type.__name__}_{member_name}",
|
|
395
|
+
fallback=definition.fallback,
|
|
396
|
+
dependencies=[self._native_dependency(x) for x in route_dependencies],
|
|
397
|
+
)(endpoint)
|
|
398
|
+
definition._native = native
|
|
399
|
+
self._actions[id(definition)] = native
|
|
400
|
+
|
|
401
|
+
def _request(self) -> Any:
|
|
402
|
+
try:
|
|
403
|
+
from hedron.routing.router import current_request
|
|
404
|
+
|
|
405
|
+
return current_request.get()
|
|
406
|
+
except (ImportError, LookupError):
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
def _mount_fragment(self, definition: Fragment[Any], arguments: Mapping[str, Any]) -> None:
|
|
410
|
+
native = self._fragments.get(id(definition))
|
|
411
|
+
if native is None:
|
|
412
|
+
raise BindingError("fragment is not registered on this app", code="EDRON_FRAGMENT_APP")
|
|
413
|
+
try:
|
|
414
|
+
node = native.bind(**dict(arguments))() if arguments else native()
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
raise BindingError(
|
|
417
|
+
f"could not bind fragment {definition.name}", code="EDRON_FRAGMENT_BIND"
|
|
418
|
+
) from exc
|
|
419
|
+
from edron._internal import require_frame
|
|
420
|
+
|
|
421
|
+
require_frame("page", "fragment").buffer.append(node)
|
|
422
|
+
|
|
423
|
+
def _resolve_action(self, value: Any) -> Any:
|
|
424
|
+
if isinstance(value, BoundAction):
|
|
425
|
+
native = self._actions.get(id(value.action))
|
|
426
|
+
if native is None:
|
|
427
|
+
return None
|
|
428
|
+
return self._bind_action(native, value.arguments)
|
|
429
|
+
if isinstance(value, Action):
|
|
430
|
+
return self._actions.get(id(value))
|
|
431
|
+
return value
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _bind_action(native: Any, arguments: Mapping[str, Any]) -> Any:
|
|
435
|
+
if not arguments:
|
|
436
|
+
return native
|
|
437
|
+
parts = urlsplit(str(native.path))
|
|
438
|
+
path = parts.path
|
|
439
|
+
query: dict[str, Any] = dict()
|
|
440
|
+
for name, value in arguments.items():
|
|
441
|
+
marker = re.compile(r"\{" + re.escape(name) + r"(?::[^}]+)?\}")
|
|
442
|
+
if marker.search(path):
|
|
443
|
+
path = marker.sub(quote(str(value), safe=""), path)
|
|
444
|
+
else:
|
|
445
|
+
query[name] = value
|
|
446
|
+
encoded = urlencode(query, doseq=True)
|
|
447
|
+
combined_query = "&".join(item for item in (parts.query, encoded) if item)
|
|
448
|
+
return replace(
|
|
449
|
+
native,
|
|
450
|
+
path=urlunsplit((parts.scheme, parts.netloc, path, combined_query, parts.fragment)),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def _action_button(self, label: str, action: Any, **kwargs: Any) -> Any:
|
|
454
|
+
native = self._resolve_action(action)
|
|
455
|
+
if native is None or not hasattr(native, "button"):
|
|
456
|
+
raise BindingError(
|
|
457
|
+
"button requires a registered Edron action", code="EDRON_ACTION_BIND"
|
|
458
|
+
)
|
|
459
|
+
confirm = kwargs.pop("confirm", None)
|
|
460
|
+
if confirm is not None:
|
|
461
|
+
kwargs["hx-confirm"] = getattr(confirm, "message", confirm)
|
|
462
|
+
variant = kwargs.pop("variant", None)
|
|
463
|
+
size = kwargs.pop("size", None)
|
|
464
|
+
width = kwargs.pop("width", None)
|
|
465
|
+
classes = ["edron-action-button"]
|
|
466
|
+
if variant:
|
|
467
|
+
classes.append(f"edron-action-button--{variant}")
|
|
468
|
+
if size:
|
|
469
|
+
classes.append(f"edron-action-button--{size}")
|
|
470
|
+
if width:
|
|
471
|
+
classes.append(f"edron-action-button--{width}")
|
|
472
|
+
kwargs["class_"] = " ".join(classes)
|
|
473
|
+
return native.button(
|
|
474
|
+
label, **{key: value for key, value in kwargs.items() if value is not None}
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _action_form(self, action: Any, *, model: Any = None, **kwargs: Any) -> Any:
|
|
478
|
+
native = self._resolve_action(action)
|
|
479
|
+
if native is None or not hasattr(native, "form"):
|
|
480
|
+
raise BindingError("form requires a registered Edron action", code="EDRON_ACTION_BIND")
|
|
481
|
+
if model is not None and getattr(native, "input_model", None) is None:
|
|
482
|
+
raise BindingError(
|
|
483
|
+
"the action does not have a typed form body", code="EDRON_FORM_MODEL"
|
|
484
|
+
)
|
|
485
|
+
return native.form(**kwargs)
|
|
486
|
+
|
|
487
|
+
def include(self, feature: Any) -> Any:
|
|
488
|
+
return self.hedron.include_feature(feature)
|
|
489
|
+
|
|
490
|
+
def styles(self, name: str, source: str | Path, **kwargs: Any) -> Any:
|
|
491
|
+
return self.hedron.styles(name, source, **kwargs)
|
|
492
|
+
|
|
493
|
+
def seal(self) -> None:
|
|
494
|
+
self._sealed = True
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def hedron_core_page(*nodes: Any, title: str | None = None) -> Any:
|
|
498
|
+
from hedron_core.builtins.document import Page as NativePage
|
|
499
|
+
|
|
500
|
+
return NativePage(*nodes, title=title)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def hedron_core_fragment(*nodes: Any) -> Any:
|
|
504
|
+
from hedron_core.builtins.document import Fragment as NativeFragment
|
|
505
|
+
|
|
506
|
+
return NativeFragment(*nodes)
|
edron/cache.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from threading import RLock
|
|
7
|
+
from typing import Any, ParamSpec, TypeVar
|
|
8
|
+
|
|
9
|
+
from edron.errors import BindingError
|
|
10
|
+
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
R = TypeVar("R")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CachedFunction:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
fn: Callable[..., Any],
|
|
19
|
+
*,
|
|
20
|
+
ttl: float | None = None,
|
|
21
|
+
scope: str = "private",
|
|
22
|
+
max_entries: int = 128,
|
|
23
|
+
) -> None:
|
|
24
|
+
if scope not in {"private", "tenant", "public"}:
|
|
25
|
+
raise BindingError(
|
|
26
|
+
"cache scope must be private, tenant, or public", code="EDRON_CACHE_SCOPE"
|
|
27
|
+
)
|
|
28
|
+
self.fn = fn
|
|
29
|
+
self.ttl = ttl
|
|
30
|
+
self.scope = scope
|
|
31
|
+
self.max_entries = max_entries
|
|
32
|
+
self._values: OrderedDict[Any, Any] = OrderedDict()
|
|
33
|
+
self._lock = RLock()
|
|
34
|
+
wraps(fn)(self)
|
|
35
|
+
|
|
36
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
37
|
+
key = (args, tuple(sorted(kwargs.items())))
|
|
38
|
+
with self._lock:
|
|
39
|
+
if key in self._values:
|
|
40
|
+
self._values.move_to_end(key)
|
|
41
|
+
return self._values[key]
|
|
42
|
+
value = self.fn(*args, **kwargs)
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._values[key] = value
|
|
45
|
+
self._values.move_to_end(key)
|
|
46
|
+
while len(self._values) > self.max_entries:
|
|
47
|
+
self._values.popitem(last=False)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
def invalidate(self, *args: Any, **kwargs: Any) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._values.pop((args, tuple(sorted(kwargs.items()))), None)
|
|
53
|
+
|
|
54
|
+
def invalidate_all(self) -> None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._values.clear()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cache_data(
|
|
60
|
+
*, ttl: float | None = None, scope: str = "private", max_entries: int = 128
|
|
61
|
+
) -> Callable[[Callable[P, R]], CachedFunction]:
|
|
62
|
+
return lambda fn: CachedFunction(fn, ttl=ttl, scope=scope, max_entries=max_entries)
|