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 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)