diff --git a/src/demos/basic.py b/src/demos/basic.py index 4a28d01..ac48bb1 100644 --- a/src/demos/basic.py +++ b/src/demos/basic.py @@ -599,13 +599,33 @@ async def io_display_metadata(io: IO): default_value="list", ) + def sync_fn(): + return "Called it" + + async def async_fn(): + await asyncio.sleep(1.5) + return "Done!" + + async def another(): + await asyncio.sleep(2) + return "Slept!" + + async def raises_error(): + raise Exception("Oops!") + await io.display.metadata( "User info", layout=layout, data=[ {"label": "Name", "value": "Alex"}, {"label": "Email", "value": "alex@interval.com"}, + {"label": "Not defined"}, {"label": "Friends", "value": 24}, + {"label": "None", "value": None}, + {"label": "Function", "value": sync_fn}, + {"label": "Async function", "value": async_fn}, + {"label": "Raises an error", "value": raises_error}, + {"label": "Task", "value": another()}, ], ) diff --git a/src/interval_sdk/classes/component.py b/src/interval_sdk/classes/component.py index ded0251..0fb74ed 100644 --- a/src/interval_sdk/classes/component.py +++ b/src/interval_sdk/classes/component.py @@ -137,12 +137,14 @@ async def set_state(self, value: Any): try: parsed = parse_obj_as(self.schema.state, dict_keys_to_snake(value)) if self._handle_state_change: - self.instance.props = await self._handle_state_change( - parsed, - parse_obj_as( - self.schema.props, - dict_keys_to_snake(self.instance.props), - ), + await self.set_props( + await self._handle_state_change( + parsed, + parse_obj_as( + self.schema.props, + dict_keys_to_snake(self.instance.props), + ), + ) ) elif parsed is not None: print( @@ -150,13 +152,17 @@ async def set_state(self, value: Any): file=sys.stderr, ) - if self.on_state_change is not None: - # This is definitely callable? - # pylint: disable-next=not-callable - await self.on_state_change() except ValidationError as err: print("[Interval] Received invalid state:", value, err, file=sys.stderr) + async def set_props(self, value: Any): + self.instance.props = value + + if self.on_state_change is not None: + # This is definitely callable? + # pylint: disable-next=not-callable + await self.on_state_change() + def parse_return_value(self, value: Any): return_schema = self.schema.returns if self.instance.is_multiple: diff --git a/src/interval_sdk/classes/io.py b/src/interval_sdk/classes/io.py index e86fc4c..40dab5b 100644 --- a/src/interval_sdk/classes/io.py +++ b/src/interval_sdk/classes/io.py @@ -1,5 +1,6 @@ import asyncio import base64 +from inspect import isawaitable import sys from dataclasses import dataclass from datetime import date, datetime, time @@ -18,6 +19,11 @@ from typing_extensions import Never from urllib.parse import ParseResult, urlparse +from pydantic.fields import Undefined + +from interval_sdk.classes.logger import Logger +from interval_sdk.superjson.transformer import UNDEFINED + from ..io_schema import ( ButtonItem, ButtonItemModel, @@ -144,6 +150,7 @@ class IO: @dataclass class Input: _renderer: ComponentRenderer + _logger: Logger _display_resolves_immediately: Optional[bool] def text( @@ -536,6 +543,7 @@ def get_value(val: InnerFileModel) -> IntervalFile: @dataclass class Select: _renderer: ComponentRenderer + _logger: Logger _display_resolves_immediately: Optional[bool] def table( @@ -863,6 +871,7 @@ def get_value( @dataclass class Display: _renderer: ComponentRenderer + _logger: Logger _display_resolves_immediately: Optional[bool] def code( @@ -946,15 +955,66 @@ def metadata( data: Iterable[MetaItemDefinition], layout: MetadataLayout = "grid", ) -> DisplayIOPromise[Literal["DISPLAY_METADATA"], None]: + eventual_props = ["value", "url", "image", "route", "params"] + + new_data: list[MetaItemDefinition] = [] + model_data: list[MetaItemDefinitionModel] = [] + + for item in data: + new_item: MetaItemDefinition = {"label": item["label"]} + model_item = MetaItemDefinitionModel(label=item["label"]) + + for prop in eventual_props: + if prop in item: + prop_val = item[prop] + if callable(prop_val): + prop_val = prop_val() + + new_item[prop] = prop_val + + if isawaitable(prop_val): + model_item.__setattr__(prop, UNDEFINED) + elif prop_val is not None: + model_item.__setattr__(prop, prop_val) + + new_data.append(new_item) + model_data.append(model_item) + c = Component( method_name="DISPLAY_METADATA", label=label, initial_props=DisplayMetadataProps( layout=layout, - data=[MetaItemDefinitionModel.parse_obj(item) for item in data], + data=model_data, ), display_resolves_immediately=self._display_resolves_immediately, ) + + loop = asyncio.get_running_loop() + for i, item in enumerate(new_data): + for prop in eventual_props: + if prop in item and isawaitable(item[prop]): + + async def handle_wait( + item: MetaItemDefinition, + prop: str, + i: int, + ): + try: + value = await item[prop] + item[prop] = value + model_data[i].__setattr__(prop, value) + await c.set_props( + DisplayMetadataProps(layout=layout, data=model_data) + ) + except Exception as err: + self._logger.error( + f'Error updating metadata field "{prop}" with result from async task:', + err, + ) + + _task = loop.create_task(handle_wait(item, prop, i)) + return DisplayIOPromise(c, renderer=self._renderer) def object( @@ -1289,6 +1349,7 @@ def video( @dataclass class Experimental: _renderer: ComponentRenderer + _logger: Logger _display_resolves_immediately: Optional[bool] def spreadsheet( @@ -1314,6 +1375,7 @@ def spreadsheet( ) return InputIOPromise(c, renderer=self._renderer) + _logger: Logger _renderer: ComponentRenderer _display_resolves_immediately: Optional[bool] input: Input @@ -1325,21 +1387,31 @@ def __init__( self, renderer: ComponentRenderer, *, + logger: Logger, display_resolves_immediately: Optional[bool] = None, ): + self._logger = logger self._renderer = renderer self._display_resolves_immediately = display_resolves_immediately self.input = self.Input( - renderer, _display_resolves_immediately=display_resolves_immediately + renderer, + _logger=logger, + _display_resolves_immediately=display_resolves_immediately, ) self.select = self.Select( - renderer, _display_resolves_immediately=display_resolves_immediately + renderer, + _logger=logger, + _display_resolves_immediately=display_resolves_immediately, ) self.display = self.Display( - renderer, _display_resolves_immediately=display_resolves_immediately + renderer, + _logger=logger, + _display_resolves_immediately=display_resolves_immediately, ) self.experimental = self.Experimental( - renderer, _display_resolves_immediately=display_resolves_immediately + renderer, + _logger=logger, + _display_resolves_immediately=display_resolves_immediately, ) def confirm( diff --git a/src/interval_sdk/classes/io_client.py b/src/interval_sdk/classes/io_client.py index c49ab89..be58a55 100644 --- a/src/interval_sdk/classes/io_client.py +++ b/src/interval_sdk/classes/io_client.py @@ -44,6 +44,7 @@ def __init__( self.io = IO( self.render_components, + logger=logger, display_resolves_immediately=display_resolves_immediately, ) diff --git a/src/interval_sdk/classes/layout.py b/src/interval_sdk/classes/layout.py index dc72dac..76cb733 100644 --- a/src/interval_sdk/classes/layout.py +++ b/src/interval_sdk/classes/layout.py @@ -1,15 +1,14 @@ -import json from dataclasses import dataclass -from typing import Any, Callable, Optional, Union +from typing import Optional -from typing_extensions import Literal, TypeAlias, Awaitable +from typing_extensions import Literal, TypeAlias from pydantic import Field from ..classes.io_promise import DisplayIOPromise from ..types import BaseModel from ..io_schema import ButtonItem, ButtonItemModel, IORender -from ..util import dump_snake_obj, json_loads_camel, snake_to_camel +from ..util import Eventual PageLayoutKey = Literal["title", "description", "children", "menuItems"] @@ -22,7 +21,7 @@ class PageError(BaseModel): stack: Optional[str] = None -EventualStr: TypeAlias = Union[str, Awaitable[str], Callable[[], Awaitable[str]]] +EventualStr: TypeAlias = Eventual[str] @dataclass diff --git a/src/interval_sdk/io_schema.py b/src/interval_sdk/io_schema.py index 81e53e8..6dee605 100644 --- a/src/interval_sdk/io_schema.py +++ b/src/interval_sdk/io_schema.py @@ -36,6 +36,7 @@ GenericModel, ) from .util import ( + Eventual, ObjectLiteral, json_dumps_strip_none, json_loads_strip_none, @@ -676,11 +677,11 @@ class DisplayLinkProps(BaseModel): class MetaItemDefinition(TypedDict): label: str - value: NotRequired[Optional[ObjectLiteral]] - url: NotRequired[str] - image: NotRequired[ImageDefinition] - route: NotRequired[str] - params: NotRequired[SerializableRecord] + value: NotRequired[Optional[Eventual[ObjectLiteral]]] + url: NotRequired[Eventual[str]] + image: NotRequired[Eventual[ImageDefinition]] + route: NotRequired[Eventual[str]] + params: NotRequired[Eventual[SerializableRecord]] class MetaItemDefinitionModel(BaseModel): diff --git a/src/interval_sdk/superjson/transformer.py b/src/interval_sdk/superjson/transformer.py index 6b4ef51..b4ccb65 100644 --- a/src/interval_sdk/superjson/transformer.py +++ b/src/interval_sdk/superjson/transformer.py @@ -3,7 +3,9 @@ from typing import Any, Callable, Union from typing_extensions import TypeAlias, Literal -LeafTypeAnnotation: TypeAlias = Literal["number", "Date", "regexp", "set", "map"] +LeafTypeAnnotation: TypeAlias = Literal[ + "number", "Date", "regexp", "set", "map", "undefined" +] CustomTypeAnnotation: TypeAlias = tuple[Literal["custom"], str] @@ -19,7 +21,16 @@ } +class Undefined: + pass + + +UNDEFINED = Undefined() + + def transform_value(value: Any) -> Union[tuple[Any, TypeAnnotation], None]: + if isinstance(value, Undefined): + return (None, "undefined") if isinstance(value, float): if math.isinf(value): return ("Infinity", "number") diff --git a/src/interval_sdk/util.py b/src/interval_sdk/util.py index 193f641..53b5d18 100644 --- a/src/interval_sdk/util.py +++ b/src/interval_sdk/util.py @@ -1,5 +1,15 @@ import json, re -from typing import Any, Iterable, Mapping, Optional, Tuple, Callable, Union, cast +from typing import ( + Any, + Awaitable, + Iterable, + Mapping, + Optional, + Tuple, + Callable, + Union, + cast, +) from datetime import date, time, datetime from typing_extensions import TypeAlias, TypeVar from time import time_ns @@ -174,6 +184,10 @@ def json_loads_strip_none(*args, **kwargs) -> Any: return dict_strip_none(obj) +Eventual: TypeAlias = Union[ + T, Awaitable[T], Callable[[], T], Callable[[], Awaitable[T]] +] + Deserializable: TypeAlias = Union[int, float, bool, None, str] DeserializableRecord: TypeAlias = Mapping[str, Deserializable] Serializable: TypeAlias = Union[bool, int, float, datetime, date, time, str, None] diff --git a/src/tests/test_main.py b/src/tests/test_main.py index a0e9767..a66f152 100644 --- a/src/tests/test_main.py +++ b/src/tests/test_main.py @@ -237,6 +237,14 @@ async def test_display_metadata( ): @interval.action("io.display.metadata") async def display_metadata(io: IO): + async def task_value(): + await asyncio.sleep(1) + return "Done!" + + async def async_fn_value(): + await asyncio.sleep(1.5) + return "Did it" + data: list[MetaItemDefinition] = [ { "label": "Is true", @@ -280,11 +288,48 @@ async def display_metadata(io: IO): "size": "small", }, }, + { + "label": "Is a function", + "value": lambda: "Called it", + }, + { + "label": "Is an async function", + "value": async_fn_value, + }, ] - await io.display.metadata("Metadata list", data=data) - await io.display.metadata("Metadata grid", layout="grid", data=data) - await io.display.metadata("Metadata card", layout="card", data=data) + await io.display.metadata( + "Metadata list", + data=[ + *data, + { + "label": "Is a task", + "value": task_value(), + }, + ], + ) + await io.display.metadata( + "Metadata grid", + layout="grid", + data=[ + *data, + { + "label": "Is a task", + "value": task_value(), + }, + ], + ) + await io.display.metadata( + "Metadata card", + layout="card", + data=[ + *data, + { + "label": "Is a task", + "value": task_value(), + }, + ], + ) await transactions.console() await transactions.run("io.display.metadata") @@ -312,6 +357,17 @@ async def display_metadata(io: IO): await expect(page.locator('dt:has-text("Action link")').nth(i)).to_be_visible() await expect(page.locator('dd a:has-text("Click me")').nth(i)).to_be_visible() + await expect( + page.locator('dt:has-text("Is a function")').nth(i) + ).to_be_visible() + await expect(page.locator('dd:has-text("Called it")').nth(i)).to_be_visible() + await expect(page.locator('dt:has-text("Is a task")').nth(i)).to_be_visible() + await expect(page.locator('dd:has-text("Done!")').nth(i)).to_be_visible() + await expect( + page.locator('dt:has-text("Is an async function")').nth(i) + ).to_be_visible() + await expect(page.locator('dd:has-text("Did it")').nth(i)).to_be_visible() + await transactions.expect_success()