acUIty-core 0.2.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.
acuity/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ ## acuity
2
+ import asyncio
3
+ import logging
4
+ import os
5
+ from typing import Optional
6
+
7
+ from acuity.model import DataletType
8
+ from acuity.app import app
9
+ from acuity.model._lucide import LucideIconName
10
+ from acuity.datalets.form._Form import _Form
11
+ from acuity.datalets.cards._CardGrid import _CardGrid
12
+ from acuity.datalets._Mock import _Mock
13
+ from acuity.runtime.stream._stream_handler import channel, Channel
14
+
15
+ logging.basicConfig(
16
+ level=getattr(logging, os.environ.get("ACUITY_LOG_LEVEL", "INFO").upper(), logging.INFO),
17
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
18
+ )
19
+
20
+
21
+
22
+ def title(value: str):
23
+ app.title = value
24
+
25
+ def description(value: str):
26
+ app.description = value
27
+
28
+ def hideBreadcrumb():
29
+ app.breadcrumb_visible = False
30
+
31
+ def logo(image_path: str):
32
+ app.setup_logo(image_path)
33
+
34
+ def load_image(image_name: str, image_path: str):
35
+ app.load_image(image_name, image_path)
36
+
37
+ def create_page(menu_title: str, route_path: str, icon: Optional[LucideIconName] = "Box"):
38
+ return app.new_page(menu_title, route_path, icon=icon)
39
+
40
+ async def push_event(channel_name: str, data: dict):
41
+ ch = channel(channel_name)
42
+ await ch.push(data)
43
+
44
+ def push_event_sync(channel_name: str, data: dict):
45
+ loop = asyncio.get_event_loop()
46
+ asyncio.run_coroutine_threadsafe(push_event(channel_name, data), loop)
47
+
48
+ def create_datalet(datalet_type: DataletType):
49
+ return app.new_datalet(datalet_type)
50
+
51
+ def new_mock():
52
+ mock = _Mock()
53
+ app._add_datalet(mock)
54
+ return mock
55
+
56
+ def new_form(model=None, **kwargs):
57
+ """Create a form datalet; `model` is a pydantic class with Annotated form field markers."""
58
+ form = _Form(model, **kwargs)
59
+ app._add_datalet(form)
60
+ return form
61
+
62
+ def new_card_grid(**kwargs):
63
+ """Create a card grid datalet; the `@dl.data()` handler returns a list of `CardItem`."""
64
+ grid = _CardGrid(**kwargs)
65
+ app._add_datalet(grid)
66
+ return grid
67
+
68
+ __all__ = [
69
+ "title",
70
+ "description",
71
+ "hideBreadcrumb",
72
+ "logo",
73
+ "load_image",
74
+ "create_page",
75
+ "push_event_sync",
76
+ "create_datalet",
77
+ "new_form",
78
+ "new_card_grid",
79
+ "new_mock",
80
+ "DataletType"
81
+ ]
acuity/__main__.py ADDED
@@ -0,0 +1,19 @@
1
+ # __main__.py
2
+ import click
3
+
4
+ from . import commands
5
+
6
+ @click.group()
7
+ def cli():
8
+ pass
9
+
10
+ cli.add_command(commands.run)
11
+ cli.add_command(commands.watch)
12
+ cli.add_command(commands.add)
13
+ cli.add_command(commands.sub)
14
+ cli.add_command(commands.mul)
15
+ cli.add_command(commands.div)
16
+
17
+ # enables module to be run as a script
18
+ if __name__ == "__main__":
19
+ cli()
acuity/app/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from acuity.model import App, DataletType
2
+
3
+ app = App()
4
+
5
+ __all__ = ["app"]
@@ -0,0 +1,4 @@
1
+ from ._chat_container import _new_chat_container as new_chat_container
2
+ from ._empty import _new_empty as new_empty
3
+
4
+ __all__ = ["new_chat_container", "new_empty"]
@@ -0,0 +1,3 @@
1
+ def _new_chat_container():
2
+ pass
3
+
@@ -0,0 +1,4 @@
1
+ from acuity.model import Datalet
2
+
3
+ def _new_empty():
4
+ return Datalet()
acuity/commands.py ADDED
@@ -0,0 +1,43 @@
1
+ # commands.py
2
+
3
+ import click
4
+ from acuity import runtime
5
+
6
+
7
+ @click.command(help="Run acuity App")
8
+ @click.option("--port", default=8000, help="Port to run the app on.")
9
+ @click.argument("file_path", type=click.Path(exists=True))
10
+ def run(file_path, port):
11
+ runtime.run(file_path, port=port)
12
+ pass
13
+
14
+ @click.command(help="Watch acuity App")
15
+ @click.option("--port", default=8000, help="Port to watch the app on.")
16
+ @click.argument("file_path", type=click.Path(exists=True))
17
+ def watch(file_path, port):
18
+ runtime.watch(file_path, port=port)
19
+ pass
20
+
21
+ @click.command(help="Add two numbers.")
22
+ @click.argument("a", type=click.FLOAT)
23
+ @click.argument("b", type=click.FLOAT)
24
+ def add(a, b):
25
+ click.echo(a + b)
26
+
27
+ @click.command(help="Subtract two numbers.")
28
+ @click.argument("a", type=click.FLOAT)
29
+ @click.argument("b", type=click.FLOAT)
30
+ def sub(a, b):
31
+ click.echo(a - b)
32
+
33
+ @click.command(help="Multiply two numbers.")
34
+ @click.argument("a", type=click.FLOAT)
35
+ @click.argument("b", type=click.FLOAT)
36
+ def mul(a, b):
37
+ click.echo(a * b)
38
+
39
+ @click.command(help="Divide two numbers.")
40
+ @click.argument("a", type=click.FLOAT)
41
+ @click.argument("b", type=click.FLOAT)
42
+ def div(a, b):
43
+ click.echo(a / b)
@@ -0,0 +1,15 @@
1
+ from typing import Dict, Any
2
+
3
+ from acuity.model._datalet import _Datalet, _DataletType
4
+
5
+
6
+ class _Mock(_Datalet):
7
+
8
+ def __init__(self):
9
+ super().__init__(_DataletType.MOCK)
10
+
11
+ def metadata(self) -> Dict[str, Any]:
12
+ return {
13
+ "name": "Mock",
14
+ "description": "A mock datalet for testing purposes.",
15
+ }
File without changes
@@ -0,0 +1,20 @@
1
+ from typing import Any, Dict, List
2
+ from acuity.model._datalet import _Datalet, _DataletType
3
+ from acuity.datalets.cards._model import _CardItem
4
+
5
+
6
+ class _CardGrid(_Datalet[List[_CardItem]]):
7
+ """Python binder of the CardGrid datalet; the data handler returns the list of cards."""
8
+
9
+ title: str = ""
10
+
11
+ def __init__(self, title: str = ""):
12
+ super().__init__(_DataletType.CARD_GRID)
13
+ self.title = title
14
+
15
+ def with_title(self, title: str) -> "_CardGrid":
16
+ self.title = title
17
+ return self
18
+
19
+ def metadata(self) -> Dict[str, Any]:
20
+ return {"title": self.title}
@@ -0,0 +1,6 @@
1
+ from acuity.datalets.cards._model import (
2
+ _CardItem as CardItem,
3
+ _CardKpi as CardKpi,
4
+ )
5
+
6
+ __all__ = ["CardItem", "CardKpi"]
@@ -0,0 +1,18 @@
1
+ from typing import List, Optional, Union
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class _CardKpi(BaseModel):
6
+ label: str
7
+ value: Union[str, int, float]
8
+ trend: Optional[float]
9
+
10
+
11
+ class _CardItem(BaseModel):
12
+ """Card shown by the CardGrid datalet (mirrors CardItem in CardsContainer.svelte)."""
13
+ id: Optional[Union[str, int]] = None
14
+ title: str
15
+ description: Optional[str] = None
16
+ image: Optional[str] = None
17
+ kpis: List[_CardKpi] = Field(default_factory=list)
18
+ tags: List[str] = Field(default_factory=list)
@@ -0,0 +1,143 @@
1
+ from typing import Any, Dict, List, Optional, Type
2
+ from pydantic import BaseModel, Field
3
+ from pydantic_core import PydanticUndefined
4
+ from acuity.model._datalet import _Datalet, _DataletType
5
+ from acuity.datalets.form._markers import FormField, Group
6
+
7
+
8
+ class _Form(_Datalet[BaseModel]):
9
+ """Datalet describing a form; `to_json()` yields the same structure as
10
+ web/stories/bricks/organisms/forms/metadata.ts (fluent/functional builder)."""
11
+
12
+ title: str = ""
13
+ abstract: str = ""
14
+ lock_unlock: bool = False
15
+ tabs: List[Any] = Field(default_factory=list)
16
+ defaults: Dict[str, Any] = Field(default_factory=dict)
17
+ validation_schema: Dict[str, Any] = Field(default_factory=dict)
18
+ groups: List[Dict[str, Any]] = Field(default_factory=list)
19
+ submit_label: Optional[str] = None
20
+ cancel_label: Optional[str] = None
21
+
22
+ def __init__(self, model: Optional[Type[BaseModel]] = None, title: str = "", abstract: str = "",
23
+ lock_unlock: bool = False):
24
+ """Create a form, optionally from a pydantic class whose fields are annotated with
25
+ `acuity.datalets._form` markers, e.g. `domain: Annotated[str, Select(group="target")]`.
26
+ Fields without a marker are ignored; field defaults become form defaults."""
27
+ super().__init__(_DataletType.FORM)
28
+ self.title = title
29
+ self.abstract = abstract
30
+ self.lock_unlock = lock_unlock
31
+ if model is not None:
32
+ self.from_model(model)
33
+
34
+ def from_model(self, model: Type[BaseModel]) -> "_Form":
35
+ for name, info in model.model_fields.items():
36
+ group = next((m for m in info.metadata if isinstance(m, Group)), None)
37
+ gtype = info.annotation
38
+ if group is not None and isinstance(gtype, type) and issubclass(gtype, BaseModel):
39
+ gname = group.name or name
40
+ self.add_group(gname, group.title, group.description)
41
+ self._from_fields(gtype, gname)
42
+ self._from_fields(model, None)
43
+ return self
44
+
45
+ def _from_fields(self, model: Type[BaseModel], group: Optional[str]) -> None:
46
+ for name, info in model.model_fields.items():
47
+ marker = next((m for m in info.metadata if isinstance(m, FormField)), None)
48
+ if marker is None:
49
+ continue
50
+ gname = group if group is not None else marker.group
51
+ self.add_field(
52
+ gname, name, type=marker.type, label=marker.label, placeholder=marker.placeholder,
53
+ cols=marker.cols, options=getattr(marker, "options", None), attributes=marker.attributes,
54
+ remote=marker.remote, depends_on=marker.depends_on, params=marker.params)
55
+ if info.default is not PydanticUndefined and info.default is not None:
56
+ self.with_defaults(gname, {name: info.default})
57
+
58
+ def with_title(self, title: str) -> "_Form":
59
+ self.title = title
60
+ return self
61
+
62
+ def with_abstract(self, abstract: str) -> "_Form":
63
+ self.abstract = abstract
64
+ return self
65
+
66
+ def with_lock_unlock(self, value: bool = True) -> "_Form":
67
+ self.lock_unlock = value
68
+ return self
69
+
70
+ def withSubmitLabel(self, label: str) -> "_Form":
71
+ self.submit_label = label
72
+ return self
73
+
74
+ def withCancelLabel(self, label: str) -> "_Form":
75
+ self.cancel_label = label
76
+ return self
77
+
78
+ def with_defaults(self, group: str, values: Dict[str, Any]) -> "_Form":
79
+ self.defaults.setdefault(group, {}).update(values)
80
+ return self
81
+
82
+ def with_validation_schema(self, schema: Dict[str, Any]) -> "_Form":
83
+ self.validation_schema = schema
84
+ return self
85
+
86
+ def add_group(self, name: str, title: Optional[str] = None, description: str = "",
87
+ fields: Optional[List[Dict[str, Any]]] = None) -> "_Form":
88
+ self.groups.append({
89
+ "name": name,
90
+ "title": title if title is not None else name,
91
+ "description": description,
92
+ "fields": list(fields or []),
93
+ })
94
+ return self
95
+
96
+ def add_field(self, group: str, name: str, type: str = "text", label: Optional[str] = None,
97
+ placeholder: str = "", cols: int = 2, options: Optional[List[Dict[str, Any]]] = None,
98
+ attributes: Optional[Dict[str, Any]] = None, remote: Optional[bool] = None,
99
+ depends_on: Optional[List[str]] = None, params: Optional[List[str]] = None) -> "_Form":
100
+ field: Dict[str, Any] = {
101
+ "name": name,
102
+ "type": type,
103
+ "label": label if label is not None else name,
104
+ "placeholder": placeholder,
105
+ "cols": cols,
106
+ }
107
+ if options is not None:
108
+ field["options"] = options
109
+ if attributes is not None:
110
+ field["attributes"] = attributes
111
+ if remote is not None:
112
+ field["remote"] = remote
113
+ if depends_on is not None:
114
+ field["dependsOn"] = depends_on
115
+ if params is not None:
116
+ field["params"] = params
117
+
118
+ target = next((g for g in self.groups if g["name"] == group), None)
119
+ if target is None:
120
+ self.add_group(group)
121
+ target = self.groups[-1]
122
+ target["fields"].append(field)
123
+ return self
124
+
125
+ def metadata(self) -> Dict[str, Any]:
126
+ """Return the form metadata structure expected by the web Form brick."""
127
+ meta: Dict[str, Any] = {
128
+ "title": self.title,
129
+ "abstract": self.abstract,
130
+ "lockUnlock": self.lock_unlock,
131
+ "tabs": list(self.tabs),
132
+ "validationSchema": self.validation_schema,
133
+ "groups": self.groups,
134
+ }
135
+ actions: Dict[str, Any] = {}
136
+ if self.submit_label is not None:
137
+ actions["submitLabel"] = self.submit_label
138
+ if self.cancel_label is not None:
139
+ actions["cancelLabel"] = self.cancel_label
140
+ if actions:
141
+ meta["actions"] = actions
142
+ return meta
143
+
@@ -0,0 +1,10 @@
1
+ from acuity.datalets.form._markers import (
2
+ Group, FormField, Text, Email, Secret, Number, Date, DateTime, TextArea, Select, Combo, MultiCombo,
3
+ )
4
+
5
+ from acuity.datalets.form._model import (
6
+ _FormItemOption as FormItemOption,
7
+ )
8
+
9
+ __all__ = ["Group", "FormField", "Text", "Email", "Secret", "Number", "Date", "DateTime",
10
+ "TextArea", "Select", "Combo", "MultiCombo", "FormItemOption"]
@@ -0,0 +1,82 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, ClassVar, Dict, List, Optional
3
+
4
+ from acuity.datalets.form._model import _FormItemOption
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Group:
9
+ """`Annotated` marker declaring a form group. The annotated type is a pydantic class whose
10
+ fields carry `FormField` markers, e.g. `user: Annotated[User, Group('user', 'User', 'who')]`.
11
+ `name` defaults to the attribute name and `title` to the name."""
12
+ name: Optional[str] = None
13
+ title: Optional[str] = None
14
+ description: str = ""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FormField:
19
+ """Base `Annotated` marker describing a form field (see metadata.ts)."""
20
+ type: ClassVar[str] = "text"
21
+ label: Optional[str] = None
22
+ placeholder: str = ""
23
+ cols: int = 2
24
+ attributes: Optional[Dict[str, Any]] = None
25
+ remote: Optional[bool] = None
26
+ depends_on: Optional[List[str]] = None
27
+ params: Optional[List[str]] = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Text(FormField):
32
+ type: ClassVar[str] = "text"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Email(FormField):
37
+ type: ClassVar[str] = "email"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Secret(FormField):
42
+ type: ClassVar[str] = "secret"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Number(FormField):
47
+ type: ClassVar[str] = "number"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Date(FormField):
52
+ type: ClassVar[str] = "date"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class DateTime(FormField):
57
+ type: ClassVar[str] = "datetime"
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class TextArea(FormField):
62
+ type: ClassVar[str] = "textarea"
63
+ cols: int = 4
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Select(FormField):
68
+ type: ClassVar[str] = "select"
69
+ options: Optional[List[_FormItemOption]] = None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Combo(Select):
74
+ type: ClassVar[str] = "combo"
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class MultiCombo(Select):
79
+ type: ClassVar[str] = "multicombo"
80
+
81
+
82
+ FIELD_MARKERS = (Text, Email, Secret, Number, Date, DateTime, TextArea, Select, Combo, MultiCombo)
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class _FormItemOption(BaseModel):
5
+ value: str
6
+ label: str
7
+
8
+
9
+ class FormI:
10
+ pass
@@ -0,0 +1,6 @@
1
+ # acuity app singleton
2
+ from ._app import _App as App
3
+ from ._datalet import _Datalet as Datalet
4
+ from ._datalet import _DataletType as DataletType
5
+
6
+ __all__ = ["Datalet", "App", "DataletType"]
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class AgentRespChunk(BaseModel):
5
+ type: str
6
+ id: str
7
+ item_id: str
acuity/model/_app.py ADDED
@@ -0,0 +1,104 @@
1
+ ## acuity app properties modifiers
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, PrivateAttr, computed_field
6
+ from acuity.model._lucide import LucideIconName
7
+ from acuity.model._datalet import _Datalet, _DataletType
8
+ from acuity.model._page import _Page
9
+ import uuid
10
+
11
+ class _ImageType(Enum):
12
+ LOGO = "logo"
13
+ FAVICON = "favicon"
14
+ BANNER = "banner"
15
+ GENERIC = "generic"
16
+
17
+ class _App(BaseModel):
18
+ """App class represents the main application configuration and properties.
19
+ Handles core application settings and validates their values.
20
+ """
21
+ title: str = "acUIty App"
22
+ description: str = "lorem ipsum"
23
+ breadcrumb_visible: bool = True
24
+
25
+ _datalets: dict[str, _Datalet] = {}
26
+ _pages: dict[str, _Page] = {}
27
+ _images: dict[_ImageType, Any] = {
28
+ _ImageType.LOGO: None,
29
+ _ImageType.FAVICON: None,
30
+ _ImageType.BANNER: None,
31
+ _ImageType.GENERIC: {}
32
+ }
33
+
34
+ def datalet_by_id(self, datalet_id: str):
35
+ return self._datalets[datalet_id]
36
+
37
+ @computed_field
38
+ @property
39
+ def datalets(self) -> list[_Datalet]:
40
+ """Returns the list of datalets from the internal datalets dictionary."""
41
+ return [datalet.model_dump() for datalet in self._datalets.values()]
42
+
43
+ @computed_field
44
+ @property
45
+ def pages(self) -> list[_Page]:
46
+ """Returns the list of pages from the internal pages dictionary."""
47
+ return [page.model_dump(exclude={'panels'}) for page in self._pages.values()]
48
+
49
+ @computed_field
50
+ @property
51
+ def images(self) -> dict[str, str | dict[str, str]]:
52
+ """Returns the icons dictionary with transformed image paths."""
53
+ result = {}
54
+ for key, value in self._images.items():
55
+ if key == _ImageType.LOGO:
56
+ result["logo"] = "/image/logo"
57
+ elif key == _ImageType.FAVICON:
58
+ result["favicon"] = "/image/favicon"
59
+ elif key == _ImageType.BANNER:
60
+ result["banner"] = "/image/banner"
61
+ elif key == _ImageType.GENERIC:
62
+ result["generic"] = {
63
+ image_name: f"/image/generic/{image_name}"
64
+ for image_name in value.keys()
65
+ }
66
+ return result
67
+
68
+ def new_page(self, title: str, route_path: str, icon: LucideIconName) -> _Page:
69
+ """Create a new page with the given name and return it."""
70
+
71
+ page_id = f"page_{uuid.uuid4().hex[:8]}"
72
+
73
+ page = _Page(page_id=page_id, title=title, route_path=route_path, icon=icon)
74
+ self._pages[page_id] = page
75
+ return page
76
+
77
+ def _add_datalet(self, datalet: _Datalet) -> _Datalet:
78
+ datalet_id = datalet.id
79
+ self._datalets[datalet_id] = datalet
80
+ return datalet
81
+
82
+ def new_datalet(self, datalet_type: _DataletType) -> _Datalet:
83
+ """Create a new datalet with the given type."""
84
+
85
+ datalet_id = f"datalet_{uuid.uuid4().hex[:8]}"
86
+
87
+ datalet = _Datalet(datalet_id=datalet_id, datalet_type=datalet_type)
88
+ self._datalets[datalet_id] = datalet
89
+ return datalet
90
+
91
+ def load_image(self, image_name: str, image_path: str):
92
+ self._images[_ImageType.GENERIC][image_name] = image_path
93
+
94
+ def setup_logo(self, image_path):
95
+ self._images[_ImageType.LOGO] = image_path
96
+
97
+ def image_registry(self) -> dict[str, str]:
98
+ return self._images[_ImageType.GENERIC]
99
+
100
+ def image_path(self, image_type: _ImageType) -> str:
101
+ if image_type == _ImageType.GENERIC:
102
+ raise ValueError(f"a single image path by image type {image_type} cannot be retrieved")
103
+ return self._images[image_type]
104
+
@@ -0,0 +1,77 @@
1
+ import uuid
2
+ from enum import Enum
3
+ from typing import Optional, Callable, Dict, Any, Generic, TypeVar
4
+ from abc import ABC, abstractmethod
5
+ import json
6
+
7
+ from pydantic import BaseModel, PrivateAttr
8
+
9
+ T = TypeVar("T", bound=BaseModel)
10
+
11
+ class _DataletType(Enum):
12
+ MOCK = "Mock"
13
+ CHAT_CONTAINER = "ChatContainer"
14
+ FORM = "Form"
15
+ CARD_GRID = "CardGrid"
16
+
17
+ class _Datalet(BaseModel, ABC, Generic[T]):
18
+ """Base datalet. Subclasses parametrize it with the data model type,
19
+ e.g. `class _Chart(_Datalet[ChartData])`."""
20
+ id: str
21
+ datalet_type: _DataletType
22
+ _data_handler: Optional[Callable[..., T]] = PrivateAttr(default=None)
23
+ _item_data_handlers: Dict[str, Callable[..., Any]] = PrivateAttr(default_factory=dict)
24
+
25
+ def __init__(self, datalet_type: _DataletType):
26
+ """Initialize a new Datalet instance.
27
+ """
28
+ datalet_id = f"datalet_{uuid.uuid4().hex[:8]}"
29
+ super().__init__(id=datalet_id, datalet_type=datalet_type)
30
+
31
+ @abstractmethod
32
+ def metadata(self) -> Dict[str, Any]:
33
+ return {}
34
+
35
+ def metadata_json(self, indent: int = 2) -> str:
36
+ return json.dumps(self.metadata(), indent=indent)
37
+
38
+ def data(self) -> Callable[[Callable[..., T]], Callable[..., T]]:
39
+ """Decorator factory registering the decorated function as the data fetch
40
+ handler of this datalet. The function must return the datalet's data model `T`.
41
+ """
42
+
43
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
44
+ self._data_handler = func
45
+ return func
46
+
47
+ return decorator
48
+
49
+ def item_data(self, field: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
50
+ """Decorator factory registering the decorated function as the data item
51
+ handler of the field identified as `<group>.<name>`.
52
+ """
53
+
54
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
55
+ self._item_data_handlers[field] = func
56
+ return func
57
+
58
+ return decorator
59
+
60
+ def fetch_item_data(self, field: str) -> Callable[..., Any]:
61
+ """Return the data item handler registered for the field identified as
62
+ `<group>.<name>`."""
63
+ handler = self._item_data_handlers.get(field)
64
+ if handler is None:
65
+ raise RuntimeError(f"No item data handler registered for {field} in {self.id}")
66
+ return handler
67
+
68
+ def fetch_data(self, *args: Any, **kwargs: Any) -> T:
69
+ """Invoke the registered data handler."""
70
+ if self._data_handler is None:
71
+ raise RuntimeError(f"No data handler registered for {self.id}")
72
+ return self._data_handler(*args, **kwargs)
73
+
74
+
75
+
76
+
77
+