diff --git a/code-samples/workspaces/bonus/admin-workspace-info/workspace_admin_helper.py b/code-samples/workspaces/bonus/admin-workspace-info/workspace_admin_helper.py index 8745b3f20..409365ccd 100644 --- a/code-samples/workspaces/bonus/admin-workspace-info/workspace_admin_helper.py +++ b/code-samples/workspaces/bonus/admin-workspace-info/workspace_admin_helper.py @@ -38,7 +38,7 @@ from evo.files import FileAPIClient from evo.files.data import FileMetadata from evo.notebooks._consts import DEFAULT_DISCOVERY_URL -from evo.notebooks._helpers import build_img_widget +from evo.notebooks.assets import get as get_asset from evo.objects import ObjectAPIClient from evo.objects.data import ObjectMetadata from evo.workspaces import User, Workspace, WorkspaceAPIClient @@ -46,6 +46,14 @@ from evo.workspaces.endpoints.api import AdminApi, LicenseAccessApi +def build_img_widget(filename: str) -> widgets.Image: + return widgets.Image( + value=get_asset(filename).read_bytes(), + format="png", + layout=widgets.Layout(max_height="26px", margin="3px", align_self="center"), + ) + + class WorkspaceAdminHelper: """Aggregates administrative information across workspaces in an Evo instance. diff --git a/packages/evo-sdk-common/pyproject.toml b/packages/evo-sdk-common/pyproject.toml index 2c93cd357..6284efc48 100644 --- a/packages/evo-sdk-common/pyproject.toml +++ b/packages/evo-sdk-common/pyproject.toml @@ -26,9 +26,9 @@ Documentation = "https://developer.seequent.com/" aiohttp = ["aiohttp"] notebooks = [ "evo-sdk-common[aiohttp]", + "anywidget", "ipykernel", "ipython", - "ipywidgets", "python-dotenv", "Pillow", ] @@ -39,7 +39,7 @@ jmespath = [ [dependency-groups] # Dev dependencies. The version is left unspecified so the latest is installed. test = [ - "evo-sdk-common[aiohttp]", + "evo-sdk-common[aiohttp,notebooks]", "freezegun", "parameterized==0.9.0", "pytest", diff --git a/packages/evo-sdk-common/src/evo/notebooks/_helpers.py b/packages/evo-sdk-common/src/evo/notebooks/_helpers.py index 6611852e4..3a5431e56 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/_helpers.py +++ b/packages/evo-sdk-common/src/evo/notebooks/_helpers.py @@ -9,11 +9,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 from pathlib import Path from typing import TypeAlias -import ipywidgets as widgets - from evo.common.utils import Cache from . import assets @@ -21,6 +20,8 @@ FileName: TypeAlias = str | Path +_MEDIA_TYPES = {".gif": "image/gif", ".png": "image/png"} + def init_cache(cache_location: FileName = DEFAULT_CACHE_LOCATION) -> Cache: """Initialise the storage location for the notebook environment. @@ -37,20 +38,22 @@ def init_cache(cache_location: FileName = DEFAULT_CACHE_LOCATION) -> Cache: return cache -def build_img_widget(filename: str) -> widgets.Image: - image = assets.get(filename).read_bytes() - return widgets.Image( - value=image, - format="png", - layout=widgets.Layout(max_height="26px", margin="3px", align_self="center"), - ) +def read_asset_text(filename: str) -> str: + """Read a bundled text asset, such as the widget ESM or stylesheet. + + :param filename: The name of the file in the assets directory. + + :returns: The contents of the file. + """ + return assets.get(filename).read_text(encoding="utf-8") + +def asset_data_uri(filename: str) -> str: + """Encode a bundled image asset as a data URI so it can be embedded directly in widget markup. -def build_button_widget(text: str) -> widgets.Button: - widget = widgets.Button( - description=text, - button_style="info", - layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center"), - ) - widget.style.button_color = "#265C7F" - return widget + :param filename: The name of the file in the assets directory. + + :returns: The data URI for the image. + """ + encoded = base64.b64encode(assets.get(filename).read_bytes()).decode("ascii") + return f"data:{_MEDIA_TYPES[Path(filename).suffix.lower()]};base64,{encoded}" diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/feedback.js b/packages/evo-sdk-common/src/evo/notebooks/assets/feedback.js new file mode 100644 index 000000000..ad99c780f --- /dev/null +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/feedback.js @@ -0,0 +1,55 @@ +function render({ model, el }) { + const root = document.createElement("div"); + root.className = "evo-row"; + + const label = document.createElement("span"); + label.className = "evo-label"; + + const percent = document.createElement("span"); + percent.className = "evo-percent"; + + const progress = document.createElement("div"); + progress.className = "evo-progress"; + progress.role = "progressbar"; + + const bar = document.createElement("div"); + bar.className = "evo-progress-bar"; + progress.appendChild(bar); + + const message = document.createElement("span"); + message.className = "evo-message"; + + root.append(label, percent, progress, message); + el.appendChild(root); + + const renderLabel = () => { + label.textContent = model.get("label"); + }; + + const renderValue = () => { + const value = Math.min(Math.max(model.get("value"), 0), 1); + bar.style.width = `${value * 100}%`; + percent.textContent = `${(value * 100).toFixed(1).padStart(5)}%`; + progress.ariaValueNow = String(Math.round(value * 100)); + }; + + const renderMessage = () => { + message.textContent = model.get("message"); + }; + + model.on("change:label", renderLabel); + model.on("change:value", renderValue); + model.on("change:message", renderMessage); + + renderLabel(); + renderValue(); + renderMessage(); + + return () => { + model.off("change:label", renderLabel); + model.off("change:value", renderValue); + model.off("change:message", renderMessage); + }; +} + +export default { render }; diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/selector.js b/packages/evo-sdk-common/src/evo/notebooks/assets/selector.js new file mode 100644 index 000000000..7710b3a18 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/selector.js @@ -0,0 +1,76 @@ +function render({ model, el }) { + const root = document.createElement("div"); + root.className = "evo-row"; + + const select = document.createElement("select"); + select.className = "evo-select"; + select.id = `evo-select-${Math.random().toString(36).slice(2)}`; + + const label = document.createElement("label"); + label.className = "evo-label"; + label.htmlFor = select.id; + + const spinner = document.createElement("img"); + spinner.className = "evo-spinner"; + spinner.alt = ""; + + root.append(label, select, spinner); + el.appendChild(root); + + const renderLabel = () => { + label.textContent = model.get("label"); + }; + + const renderOptions = () => { + select.replaceChildren(); + for (const [text, value] of model.get("options") ?? []) { + const option = document.createElement("option"); + option.value = value; + option.textContent = text; + select.appendChild(option); + } + select.value = model.get("value"); + }; + + const renderValue = () => { + select.value = model.get("value"); + }; + + const renderDisabled = () => { + select.disabled = model.get("disabled"); + }; + + const renderLoading = () => { + const loading = model.get("loading"); + spinner.src = loading ? model.get("spinner") : ""; + spinner.style.display = loading ? "inline-block" : "none"; + }; + + const onChange = () => { + model.set("value", select.value); + model.save_changes(); + }; + + select.addEventListener("change", onChange); + model.on("change:label", renderLabel); + model.on("change:options", renderOptions); + model.on("change:value", renderValue); + model.on("change:disabled", renderDisabled); + model.on("change:loading", renderLoading); + + renderLabel(); + renderOptions(); + renderDisabled(); + renderLoading(); + + return () => { + select.removeEventListener("change", onChange); + model.off("change:label", renderLabel); + model.off("change:options", renderOptions); + model.off("change:value", renderValue); + model.off("change:disabled", renderDisabled); + model.off("change:loading", renderLoading); + }; +} + +export default { render }; diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js b/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js new file mode 100644 index 000000000..4fa2d79c1 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js @@ -0,0 +1,66 @@ +function render({ model, el }) { + const root = document.createElement("div"); + root.className = "evo-row"; + + const logo = document.createElement("img"); + logo.className = "evo-logo"; + logo.alt = "Evo"; + logo.src = model.get("logo"); + + const button = document.createElement("button"); + button.className = "evo-button"; + button.type = "button"; + + const spinner = document.createElement("img"); + spinner.className = "evo-spinner"; + spinner.alt = ""; + + const message = document.createElement("span"); + message.className = "evo-message"; + + root.append(logo, button, spinner, message); + el.appendChild(root); + + const renderButtonText = () => { + button.textContent = model.get("button_text"); + }; + + const renderDisabled = () => { + button.disabled = model.get("disabled"); + }; + + const renderLoading = () => { + const loading = model.get("loading"); + spinner.src = loading ? model.get("spinner") : ""; + spinner.style.display = loading ? "inline-block" : "none"; + }; + + const renderMessage = () => { + message.textContent = model.get("message"); + }; + + const onClick = () => { + model.send({ type: "click" }); + }; + + button.addEventListener("click", onClick); + model.on("change:button_text", renderButtonText); + model.on("change:disabled", renderDisabled); + model.on("change:loading", renderLoading); + model.on("change:message", renderMessage); + + renderButtonText(); + renderDisabled(); + renderLoading(); + renderMessage(); + + return () => { + button.removeEventListener("click", onClick); + model.off("change:button_text", renderButtonText); + model.off("change:disabled", renderDisabled); + model.off("change:loading", renderLoading); + model.off("change:message", renderMessage); + }; +} + +export default { render }; diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/widgets.css b/packages/evo-sdk-common/src/evo/notebooks/assets/widgets.css new file mode 100644 index 000000000..851395e65 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/widgets.css @@ -0,0 +1,85 @@ +.evo-row { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + margin: 5px; +} + +.evo-label { + font-family: var(--jp-ui-font-family, sans-serif); + font-size: var(--jp-ui-font-size1, 13px); + color: var(--jp-ui-font-color1, #212121); + min-width: var(--jp-widgets-label-width, 80px); +} + +.evo-select { + font-family: var(--jp-ui-font-family, sans-serif); + font-size: var(--jp-ui-font-size1, 13px); + padding: 3px 6px; + min-width: var(--jp-widgets-inline-width, 300px); + color: var(--jp-ui-font-color1, #212121); + background-color: var(--jp-layout-color1, #ffffff); + border: 1px solid var(--jp-border-color1, #bdbdbd); + border-radius: 2px; +} + +.evo-select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.evo-button { + font-family: var(--jp-ui-font-family, sans-serif); + font-size: var(--jp-ui-font-size1, 13px); + color: #ffffff; + background-color: #265c7f; + border: none; + border-radius: 2px; + padding: 5px 12px; + cursor: pointer; +} + +.evo-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.evo-logo { + max-height: 26px; + margin: 3px; +} + +.evo-spinner { + max-height: 26px; + margin: 3px; +} + +.evo-message { + font-family: var(--jp-ui-font-family, sans-serif); + font-size: var(--jp-ui-font-size1, 13px); + font-style: italic; + color: var(--jp-ui-font-color2, #616161); +} + +.evo-percent { + font-family: var(--jp-code-font-family, monospace); + font-size: var(--jp-ui-font-size1, 13px); + color: var(--jp-ui-font-color1, #212121); + white-space: pre; +} + +.evo-progress { + width: 400px; + height: 14px; + background-color: var(--jp-layout-color2, #eeeeee); + border-radius: 2px; + overflow: hidden; +} + +.evo-progress-bar { + height: 100%; + width: 0; + background-color: #265c7f; + transition: width 0.1s linear; +} diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py index 9e0c90d01..fff61f208 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -17,7 +17,8 @@ from typing import Any, Generic, TypeVar, cast from uuid import UUID -import ipywidgets as widgets +import anywidget +import traitlets from aiohttp.typedefs import StrOrURL from IPython.display import display @@ -37,7 +38,7 @@ DEFAULT_DISCOVERY_URL, DEFAULT_REDIRECT_URL, ) -from ._helpers import FileName, build_button_widget, build_img_widget, init_cache +from ._helpers import FileName, asset_data_uri, init_cache, read_asset_text from .authorizer import AuthorizationCodeAuthorizer from .env import DotEnv @@ -52,25 +53,34 @@ "WorkspaceSelectorWidget", ] +_STYLESHEET = read_asset_text("widgets.css") + + +class DropdownSelectorWidget(anywidget.AnyWidget, Generic[T]): + _esm = read_asset_text("selector.js") + _css = _STYLESHEET -class DropdownSelectorWidget(widgets.HBox, Generic[T]): UNSELECTED: tuple[str, T] + label = traitlets.Unicode("").tag(sync=True) + # Options are serialized to strings because widget state is synchronised to the frontend as JSON. + options = traitlets.List(traitlets.List(traitlets.Unicode())).tag(sync=True) + value = traitlets.Unicode("").tag(sync=True) + disabled = traitlets.Bool(True).tag(sync=True) + loading = traitlets.Bool(False).tag(sync=True) + spinner = traitlets.Unicode("").tag(sync=True) + def __init__(self, label: str, env: DotEnv) -> None: self._env = env - self.dropdown_widget = widgets.Dropdown( - options=[self.UNSELECTED], - description=label, - value=self.UNSELECTED[1], - layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="flex-start"), + unselected_label, unselected_value = self.UNSELECTED + super().__init__( + label=label, + options=[[unselected_label, self._serialize(unselected_value)]], + value=self._serialize(unselected_value), + disabled=True, + spinner=asset_data_uri("loading.gif"), ) - self.dropdown_widget.disabled = True - self.dropdown_widget.observe(self._update_selected, names="value") - - self._loading_widget = build_img_widget("loading.gif") - self._loading_widget.layout.display = "none" - - super().__init__([self.dropdown_widget, self._loading_widget]) + self.observe(self._update_selected, names="value") def _get_options(self) -> list[tuple[str, T]]: raise NotImplementedError("Subclasses must implement this method.") @@ -79,23 +89,24 @@ def _on_selected(self, value: T | None) -> None: ... @contextlib.contextmanager def _loading(self) -> Iterator[None]: - self.dropdown_widget.disabled = True - self._loading_widget.layout.display = "flex" + self.disabled = True + self.loading = True try: yield finally: - self._loading_widget.layout.display = "none" - self.dropdown_widget.disabled = False + self.loading = False + self.disabled = False def _update_selected(self, _: dict) -> None: - self.selected = new_value = self.dropdown_widget.value + self.selected = new_value = self._deserialize(self.value) self._on_selected(new_value if new_value != self.UNSELECTED[1] else None) def refresh(self) -> None: logger.debug(f"Refreshing {self.__class__.__name__} options...") - self.dropdown_widget.disabled = True + self.disabled = True selected = self.selected - self.dropdown_widget.options = options = [self.UNSELECTED] + self._get_options() + options = [self.UNSELECTED] + self._get_options() + self.options = [[label, self._serialize(value)] for label, value in options] if len(options) == 2 and selected == self.UNSELECTED[1]: # Automatically select the only option if there is only one and no missing option was previously selected. self.selected = new_value = options[1][1] @@ -113,7 +124,7 @@ def refresh(self) -> None: self._on_selected(new_value if new_value != self.UNSELECTED[1] else None) # Disable the widget if there are no options to select. - self.dropdown_widget.disabled = len(options) <= 1 + self.disabled = len(options) <= 1 @classmethod def _serialize(cls, value: T) -> str: @@ -131,15 +142,7 @@ def selected(self) -> T: @selected.setter def selected(self, value: T) -> None: self._env.set(f"{self.__class__.__name__}.selected", self._serialize(value)) - self.dropdown_widget.value = value - - @property - def disabled(self) -> bool: - return self.dropdown_widget.disabled - - @disabled.setter - def disabled(self, value: bool) -> None: - self.dropdown_widget.disabled = value + self.value = self._serialize(value) _NULL_UUID = UUID(int=0) @@ -180,7 +183,7 @@ class WorkspaceSelectorWidget(_UUIDSelectorWidget): def __init__(self, env: DotEnv, manager: ServiceManager, org_selector: OrgSelectorWidget) -> None: self._manager = manager super().__init__("Workspace", env) - org_selector.dropdown_widget.observe(self._on_org_selected, names="value") + org_selector.observe(self._on_org_selected, names="value") async def refresh_workspaces(self) -> None: with self._loading(): @@ -202,14 +205,30 @@ def _get_options(self) -> list[tuple[str, UUID]]: T_client = TypeVar("T_client", bound=BaseAPIClient) -class _ServiceManagerWidgetMeta(type(widgets.HBox), type(IContext)): - """Metaclass that combines ipywidgets and pure interfaces metaclasses.""" +class _ServiceManagerWidgetMeta(type(anywidget.AnyWidget), type(IContext)): + """Metaclass that combines anywidget and pure interfaces metaclasses.""" pass -class ServiceManagerWidget(widgets.HBox, IContext, metaclass=_ServiceManagerWidgetMeta): - def __init__(self, transport: ITransport, authorizer: IAuthorizer, discovery_url: str, cache: ICache) -> None: +class ServiceManagerWidget(anywidget.AnyWidget, IContext, metaclass=_ServiceManagerWidgetMeta): + _esm = read_asset_text("service_manager.js") + _css = _STYLESHEET + + logo = traitlets.Unicode("").tag(sync=True) + spinner = traitlets.Unicode("").tag(sync=True) + button_text = traitlets.Unicode("").tag(sync=True) + disabled = traitlets.Bool(False).tag(sync=True) + loading = traitlets.Bool(False).tag(sync=True) + message = traitlets.Unicode("").tag(sync=True) + + def __init__( + self, + transport: ITransport, + authorizer: IAuthorizer, + discovery_url: str, + cache: ICache, + ) -> None: """ :param transport: The transport to use for API requests. :param authorizer: The authorizer to use for API requests. @@ -226,36 +245,17 @@ def __init__(self, transport: ITransport, authorizer: IAuthorizer, discovery_url ) env = DotEnv(cache) - self._btn = build_button_widget("Sign In") - self._btn.on_click(self._on_click) + super().__init__( + logo=asset_data_uri("EvoBadgeCharcoal_FV.png"), + spinner=asset_data_uri("loading.gif"), + button_text="Sign In", + ) + self.on_msg(self._handle_frontend_msg) + self._org_selector = OrgSelectorWidget(env, self._service_manager) self._workspace_selector = WorkspaceSelectorWidget(env, self._service_manager, self._org_selector) - self._loading_widget = build_img_widget("loading.gif") - self._loading_widget.layout.display = "none" - - self._prompt_area = widgets.Output() - self._prompt_area.layout.display = "none" - - col_1 = widgets.VBox( - [ - widgets.HBox([build_img_widget("EvoBadgeCharcoal_FV.png"), self._btn, self._loading_widget]), - widgets.HBox([self._org_selector]), - widgets.HBox([self._workspace_selector]), - ] - ) - col_2 = widgets.VBox([self._prompt_area]) - - super().__init__( - [col_1, col_2], - layout={ - "display": "flex", - "flex_flow": "row", - "justify_content": "space-between", - "align_items": "center", - }, - ) - display(self) + display(self, self._org_selector, self._workspace_selector) @classmethod def with_auth_code( @@ -352,22 +352,24 @@ def cache(self) -> ICache: def _update_btn(self, signed_in: bool) -> None: if signed_in: - self._btn.description = "Refresh Evo Services" + self.button_text = "Refresh Evo Services" else: - self._btn.description = "Sign In" + self.button_text = "Sign In" - def _on_click(self, _: widgets.Button) -> asyncio.Future: - return asyncio.ensure_future(self.refresh_services()) + def _handle_frontend_msg(self, _widget: object, content: object, _buffers: list) -> asyncio.Future | None: + if isinstance(content, dict) and content.get("type") == "click": + return asyncio.ensure_future(self.refresh_services()) + return None @contextlib.contextmanager def _loading(self) -> Iterator[None]: - self._btn.disabled = True - self._loading_widget.layout.display = "flex" + self.disabled = True + self.loading = True try: yield finally: - self._loading_widget.layout.display = "none" - self._btn.disabled = False + self.loading = False + self.disabled = False @contextlib.contextmanager def _loading_services(self) -> Iterator[None]: @@ -378,15 +380,6 @@ def _loading_services(self) -> Iterator[None]: finally: self._org_selector.refresh() - @contextlib.contextmanager - def _prompt(self) -> Iterator[widgets.Output]: - self._prompt_area.layout.display = "flex" - try: - yield self._prompt_area - finally: - self._prompt_area.layout.display = "none" - self._prompt_area.clear_output() - async def refresh_services(self) -> None: with self._loading(): with self._loading_services(): @@ -459,6 +452,15 @@ def create_client(self, client_class: type[T_client], *args: Any, **kwargs: Any) return self._service_manager.create_client(client_class, *args, **kwargs) +class _ProgressWidget(anywidget.AnyWidget): + _esm = read_asset_text("feedback.js") + _css = _STYLESHEET + + label = traitlets.Unicode("").tag(sync=True) + value = traitlets.Float(0.0).tag(sync=True) + message = traitlets.Unicode("").tag(sync=True) + + class FeedbackWidget(IFeedback): """Simple feedback widget for displaying progress and messages to the user.""" @@ -466,11 +468,7 @@ def __init__(self, label: str) -> None: """ :param label: The label for the feedback widget. """ - label = widgets.Label(label) - self._progress = widgets.FloatProgress(value=0, min=0, max=1, style={"bar_color": "#265C7F"}) - self._progress.layout.width = "400px" - self._msg = widgets.Label("", style={"font_style": "italic"}) - self._widget = widgets.HBox([label, self._progress, self._msg]) + self._widget = _ProgressWidget(label=label) self._last_message = "" display(self._widget) @@ -482,7 +480,6 @@ def progress(self, progress: float, message: str | None = None) -> None: :param progress: A float between 0 and 1 representing the progress of the operation as a percentage. :param message: An optional message to display to the user. """ - self._progress.value = progress - self._progress.description = f"{progress * 100:5.1f}%" + self._widget.value = progress if message is not None: - self._msg.value = message + self._widget.message = message diff --git a/packages/evo-sdk-common/tests/notebooks/__init__.py b/packages/evo-sdk-common/tests/notebooks/__init__.py new file mode 100644 index 000000000..bc5bffd31 --- /dev/null +++ b/packages/evo-sdk-common/tests/notebooks/__init__.py @@ -0,0 +1,10 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs b/packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs new file mode 100644 index 000000000..53fd1011c --- /dev/null +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile(new URL("../../src/evo/notebooks/assets/service_manager.js", import.meta.url), "utf8"); +const { default: { render } } = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`); + +test("renders service manager state and sends clicks to the widget", () => { + const elements = []; + const handlers = new Map(); + const messages = []; + const values = { + logo: "logo-data", + spinner: "spinner-data", + button_text: "Sign In", + disabled: false, + loading: false, + message: "Ready", + }; + const originalDocument = globalThis.document; + globalThis.document = { + createElement: (tag) => { + const element = { + tag, + style: {}, + listeners: new Map(), + append(...children) { + this.children = children; + }, + addEventListener(event, callback) { + this.listeners.set(event, callback); + }, + removeEventListener(event, callback) { + assert.equal(this.listeners.get(event), callback); + this.listeners.delete(event); + }, + }; + elements.push(element); + return element; + }, + }; + + const model = { + get: (key) => values[key], + send: (message) => messages.push(message), + on: (event, callback) => handlers.set(event, callback), + off: (event, callback) => { + assert.equal(handlers.get(event), callback); + handlers.delete(event); + }, + }; + let root; + + try { + const cleanup = render({ model, el: { appendChild: (element) => { root = element; } } }); + const [, logo, button, spinner, message] = elements; + + assert.equal(root.className, "evo-row"); + assert.equal(logo.src, "logo-data"); + assert.equal(button.textContent, "Sign In"); + assert.equal(button.disabled, false); + assert.equal(spinner.style.display, "none"); + assert.equal(message.textContent, "Ready"); + + button.listeners.get("click")(); + assert.deepEqual(messages, [{ type: "click" }]); + + values.button_text = "Refresh Evo Services"; + handlers.get("change:button_text")(); + values.loading = true; + handlers.get("change:loading")(); + values.message = "Loaded"; + handlers.get("change:message")(); + assert.equal(button.textContent, "Refresh Evo Services"); + assert.equal(spinner.src, "spinner-data"); + assert.equal(spinner.style.display, "inline-block"); + assert.equal(message.textContent, "Loaded"); + + cleanup(); + assert.equal(button.listeners.size, 0); + assert.equal(handlers.size, 0); + } finally { + if (originalDocument === undefined) { + delete globalThis.document; + } else { + globalThis.document = originalDocument; + } + } +}); diff --git a/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py new file mode 100644 index 000000000..38672bfc8 --- /dev/null +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -0,0 +1,48 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import tempfile +import unittest +from unittest import mock + +from anywidget import AnyWidget + +from evo.common.interfaces import IContext +from evo.notebooks import ServiceManagerWidget + + +class TestServiceManagerWidget(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.widget = ServiceManagerWidget.with_auth_code(client_id="test", cache_location=self._tmp.name) + self.addCleanup(self.widget.close) + + def test_is_anywidget_and_context(self) -> None: + assert isinstance(self.widget, AnyWidget) + assert isinstance(self.widget, IContext) + + def test_sign_in_button_updates_for_auth_state(self) -> None: + assert self.widget.button_text == "Sign In" + + self.widget._update_btn(True) + assert self.widget.button_text == "Refresh Evo Services" + + self.widget._update_btn(False) + assert self.widget.button_text == "Sign In" + + async def test_frontend_click_refreshes_services(self) -> None: + with mock.patch.object(self.widget, "refresh_services", new_callable=mock.AsyncMock) as refresh: + task = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) + assert task is not None + await task + + refresh.assert_awaited_once() diff --git a/uv.lock b/uv.lock index b14423b38..7d3721ba6 100644 --- a/uv.lock +++ b/uv.lock @@ -247,6 +247,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "anywidget" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipywidgets" }, + { name = "psygnal" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/31/0491d707c674b34267f55d96d6a7148e55e7b6718a271686232cf295fbe2/anywidget-0.11.0.tar.gz", hash = "sha256:6695fbef9449cf8c27f421b96c5837aa37f909ec1f60cfa33add333e1b70b169", size = 426999, upload-time = "2026-04-27T23:42:09.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/c2/8fec8e8e2eb920cc2280f569144080cd58622a2eda83bfa4c0c354a63264/anywidget-0.11.0-py3-none-any.whl", hash = "sha256:c574d9acc6503ad27b37a9acea48f957a8ba7c9c9876cfcb37898931c098ce9d", size = 317341, upload-time = "2026-04-27T23:42:08.356Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -1338,10 +1352,10 @@ jmespath = [ ] notebooks = [ { name = "aiohttp" }, + { name = "anywidget" }, { name = "ipykernel" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "ipywidgets" }, { name = "pillow" }, { name = "python-dotenv" }, ] @@ -1350,7 +1364,7 @@ notebooks = [ dev = [ { name = "bumpver" }, { name = "coverage", extra = ["toml"] }, - { name = "evo-sdk-common", extra = ["aiohttp", "jmespath"] }, + { name = "evo-sdk-common", extra = ["aiohttp", "jmespath", "notebooks"] }, { name = "freezegun" }, { name = "parameterized" }, { name = "pre-commit" }, @@ -1362,7 +1376,7 @@ notebooks = [ { name = "jupyter" }, ] test = [ - { name = "evo-sdk-common", extra = ["aiohttp"] }, + { name = "evo-sdk-common", extra = ["aiohttp", "notebooks"] }, { name = "freezegun" }, { name = "parameterized" }, { name = "pytest" }, @@ -1371,10 +1385,10 @@ test = [ [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'" }, + { name = "anywidget", marker = "extra == 'notebooks'" }, { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, { name = "ipykernel", marker = "extra == 'notebooks'" }, { name = "ipython", marker = "extra == 'notebooks'" }, - { name = "ipywidgets", marker = "extra == 'notebooks'" }, { name = "jmespath", marker = "extra == 'jmespath'", specifier = ">=1.0.1" }, { name = "pillow", marker = "extra == 'notebooks'" }, { name = "pure-interface", specifier = ">=8.0.0" }, @@ -1388,8 +1402,8 @@ provides-extras = ["aiohttp", "notebooks", "jmespath"] dev = [ { name = "bumpver" }, { name = "coverage", extras = ["toml"] }, - { name = "evo-sdk-common", extras = ["aiohttp"], editable = "packages/evo-sdk-common" }, { name = "evo-sdk-common", extras = ["aiohttp", "jmespath"], editable = "packages/evo-sdk-common" }, + { name = "evo-sdk-common", extras = ["aiohttp", "notebooks"], editable = "packages/evo-sdk-common" }, { name = "freezegun" }, { name = "parameterized", specifier = "==0.9.0" }, { name = "pre-commit" }, @@ -1401,7 +1415,7 @@ notebooks = [ { name = "jupyter" }, ] test = [ - { name = "evo-sdk-common", extras = ["aiohttp"], editable = "packages/evo-sdk-common" }, + { name = "evo-sdk-common", extras = ["aiohttp", "notebooks"], editable = "packages/evo-sdk-common" }, { name = "freezegun" }, { name = "parameterized", specifier = "==0.9.0" }, { name = "pytest" }, @@ -3349,6 +3363,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psygnal" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/df/2a94607af05d91638646339acfc02d7c865821c21356ddcfef3d6b4c7fb6/psygnal-0.16.1.tar.gz", hash = "sha256:8e30df5e8f2a927191afacd22653924ba5ec54ca00f965f4af3ed38e37b727a9", size = 129789, upload-time = "2026-09-11T08:30:17.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/41/8c6fd391455c30d8cf073757d9c08ac691662ba4ae074f4b07f472a25f73/psygnal-0.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c039d992b66b86d7310f6a179190121d4824e02c75cafa65b656248fc371449f", size = 553302, upload-time = "2026-09-11T08:29:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a6/3b07e4557702d8510a49d2722777a69cdb4fc95017f27c15cc64c04ab493/psygnal-0.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b336b7ee156de74de7f4cbed39700fdf2e1f661706db6a4e67bf97f81cc6351", size = 505996, upload-time = "2026-09-11T08:29:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/aa2c48585852f8216c0c8a4ac5241deb35b7e754783b2ca9c71c34ad61a1/psygnal-0.16.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a95f80a2bb946658ae4533e38be4abd6bf44b566253bb91b2d3392d885c5308", size = 551462, upload-time = "2026-09-11T08:29:31.098Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9d/ac4ed784a606d5eecef921c77dbeaf21380dcbed283c8c79dd24217f6b5c/psygnal-0.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:72ce4ee30f26d471a7d7929142b516cb6ab186f86ce10793ea1a7e1ebf89ef41", size = 556053, upload-time = "2026-09-11T08:29:32.783Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/227378ed893e443c5d4b60313d3fb694f08a4c2d7f1d36176f9781d5ab48/psygnal-0.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:f16f9a8539824b8e46b7b314401c1a25046b667d15b9d524a54b93240f4ea320", size = 416479, upload-time = "2026-09-11T08:29:34.213Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/1edf89048d8b13f65a5186b5b5d600c1e08bdad5f2f37d7f182ac82ccd25/psygnal-0.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:483a3ab1780ce1eda66ed35929a9ec9e5f4bd348b052b612ce7c92302d96d9fe", size = 546088, upload-time = "2026-09-11T08:29:35.687Z" }, + { url = "https://files.pythonhosted.org/packages/ae/35/ab79b0a719af3b6b8da44bb9ac70d414c696eb58aa0d7f657a48ad681c98/psygnal-0.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9fe09145e3508086e0233e67ab45c19df2b280dea9293bbdedd198b0afe6be3d", size = 500423, upload-time = "2026-09-11T08:29:37.179Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/566160885cb535ca88041e467cfcde29e42800f6b0dba92ad6b8f47ba44d/psygnal-0.16.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:becf3f710c7880a2fda82df22e5156594e862d6291183c83bc0af0464f786cd2", size = 541341, upload-time = "2026-09-11T08:29:38.703Z" }, + { url = "https://files.pythonhosted.org/packages/42/99/de30e95ad1d9cb6febcad3b7a970e24878223b7be7fd4f7c976015b6a1e7/psygnal-0.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e332a247e8dc64e749bb922f0193287ed531807c7ee1dc2d52a91408edd3a2f2", size = 546822, upload-time = "2026-09-11T08:29:40.44Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/dfe7496ee4c4a6a3cee151c06552bf27cbf93f8bcc78c277ec9dbbc9e60d/psygnal-0.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:a83647146a550b2c1754ebf80bec8529b7031eab77b81be8264b513d4f6606d8", size = 421894, upload-time = "2026-09-11T08:29:41.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/058cd6dbdc2dee03e9ea65e81e8265244daa3e46536063de24cd58efff67/psygnal-0.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15fb2069443504e7f3ddca007e9d4d58706811c843965f3b865cfa7a60e07d65", size = 565014, upload-time = "2026-09-11T08:29:43.612Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/dfda45dd3a3d29f1917f5143127d60f53b4015c7aa6ec9e54cf87a978b44/psygnal-0.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b6c468e5e3fc99b910f0f2cfa2e644bb2f0ec5e95cfc44cbf8ca155ae8bedea", size = 503350, upload-time = "2026-09-11T08:29:45.03Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/bcca742df84341658bc1051722cd346cd9a85f3bd87e83fc2b18c636e3a2/psygnal-0.16.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48c6d80a34653e65d0da6a15aac44a59a39e28851c15304c4a8dc7d213948b2", size = 548696, upload-time = "2026-09-11T08:29:46.864Z" }, + { url = "https://files.pythonhosted.org/packages/88/32/ecb78d7520cdd2ec115beb7ec997a3f5018b420b4086e70b9103daa5fee1/psygnal-0.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b06f5374ac4802a886ff8c614a155f55672bb42d51f8dcd72cbc3adb245e077", size = 553639, upload-time = "2026-09-11T08:29:48.466Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/82203a352e7185c18c0103fd7fa1c5ba2d7b86f752e1e44b9d03ac60e553/psygnal-0.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:b89dfb659f746c42f1bfd1005d3c2b11f7b15a4b42442213d84b15c728cd27c0", size = 424646, upload-time = "2026-09-11T08:29:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/4608e35fcbee07704fd93d5495d225757a37a81407382a0bcbc19c2c3939/psygnal-0.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cd57312ea899500387a1af0c21c7a4f081a8c00b9852b5bae84b0dad3c2ed5d2", size = 559668, upload-time = "2026-09-11T08:29:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/2c190dbec5e2459606748a968069ef2d11960f10ebfa0987766ffd112e1b/psygnal-0.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5a6891674cd53d8879b311b59ec9379c7b4847693212e78063950c4e5a18942b", size = 500377, upload-time = "2026-09-11T08:29:53.198Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/f0cb8aca8111f6d4568f3f63600f55e54626bdb4e33a0f51b9b1a7b37cfb/psygnal-0.16.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eac55c333241d657f697a1adc9aa984eef47950fba798ba0cbbee5afba0b0cfc", size = 547102, upload-time = "2026-09-11T08:29:54.916Z" }, + { url = "https://files.pythonhosted.org/packages/77/64/e9c1082ca3ae11c461b5f6b693382b30359253e7cb8fbe449405be52232f/psygnal-0.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:915fe95c386ea849de48c4261d87a8542dd5418d99bcb2fa9486e5b859829058", size = 552030, upload-time = "2026-09-11T08:29:56.687Z" }, + { url = "https://files.pythonhosted.org/packages/cb/62/e46f6db7d2f503f59a1d52444417b45d2e6444eb00831f0cc9770ee7eeee/psygnal-0.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:fc377954e9ef40b1a2869e90ac62bb12e8ae910ae7db1393a0b8b3c3da4bbb4d", size = 425578, upload-time = "2026-09-11T08:29:58.447Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c1/e32d236ff4a92f2246172785fc763131db72c0c9b57d6f5a6e4a301d55ac/psygnal-0.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a1f602f53e0c276657225dd93e833baa60f3984182ff7beb82a29f2668f9b4d", size = 559095, upload-time = "2026-09-11T08:30:00.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/81cfaf385b60ab578b6d57eed0d7a99c6b3aad508f805ca93b7bebb51267/psygnal-0.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ad286847ef8ba2d4fc0326c9bdba5e5a2896afa35cc7dd42bd66320068d1dd1", size = 499091, upload-time = "2026-09-11T08:30:02.061Z" }, + { url = "https://files.pythonhosted.org/packages/12/7c/4142e20ba33f6bc799d7e80a5136fefbbfc4643089b6b9638b074346bed7/psygnal-0.16.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf3dfebad343b13765f43d1f9cff97de4b2d456568a9abcea405703896a7a154", size = 547378, upload-time = "2026-09-11T08:30:03.549Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/ff8047cf2c5ff575d01f5b404c4921b62dcd5395b87889b8e25cddf0b9e5/psygnal-0.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:035f8f92dd1ba00da86319f2d4397bb385b9e1ceaed152305230163d776dff3e", size = 552778, upload-time = "2026-09-11T08:30:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/95/ad/24f151762f8fd8b8bb34c218946b14b5f253ed1a7374d5fc4b1e3c40c999/psygnal-0.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c45b4086b929b86ad6f6cea2e4adbfca82b2ef7bf64a6872dc21696d054c0e73", size = 430639, upload-time = "2026-09-11T08:30:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/76/c0b9d5850e917394b2b90728dd1b1c495423abe16a3d4ae4d08db204a4f1/psygnal-0.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d5f2a2a7f21876480e03e6982d11be097948bfb1616830493a4fd591bfdb050d", size = 559125, upload-time = "2026-09-11T08:30:08.052Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/6f6be1f1d3a754b2cc74fbb67a5720e4a2da9d0520c49280104c4ca9d65b/psygnal-0.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:fae892bf624a7bfb7d4d713422aee4fd32f97edcaf6ef7309845f4b91ec5cdce", size = 498657, upload-time = "2026-09-11T08:30:09.533Z" }, + { url = "https://files.pythonhosted.org/packages/98/42/b7c36bcd6b4cda6bce2726a71d32e0592dec336ffa14243ca4ecfc52014c/psygnal-0.16.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44e39bfb4c1e6d31a9289baae454c55857aa3f1e2cb0066d01cdb971d7fb92b1", size = 548104, upload-time = "2026-09-11T08:30:11.002Z" }, + { url = "https://files.pythonhosted.org/packages/46/70/54b7cb0599dc1801157dc50f2846c8fb86583e56123022b1a9dc338eb0b0/psygnal-0.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6c0c0083f5d1fda072432c48f4ebc9c13e48c9bb3fbf9fb63789ab397a39b0a2", size = 553481, upload-time = "2026-09-11T08:30:12.656Z" }, + { url = "https://files.pythonhosted.org/packages/5a/58/11a9f98db54a6f27183db831a9f8fcde778f1cbb988e16e8374de70c8208/psygnal-0.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:bfb351f35c6c35dcd6d9ea3971ac295e9f40f00822a66638239aaa1e207cf0ea", size = 430505, upload-time = "2026-09-11T08:30:14.488Z" }, + { url = "https://files.pythonhosted.org/packages/be/f2/973c1280f035cd825af70582bbee776c69545e24534b324cf135eabef57e/psygnal-0.16.1-py3-none-any.whl", hash = "sha256:93b96894d8c46f0a3c0bfaaf8abe73f0d6db2c20cc6d303624cf08f85abc3c92", size = 94049, upload-time = "2026-09-11T08:30:16.039Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0"