diff --git a/packages/evo-blockmodels/docs/examples/quickstart.ipynb b/packages/evo-blockmodels/docs/examples/quickstart.ipynb index 4b16a434..b9e9e13a 100644 --- a/packages/evo-blockmodels/docs/examples/quickstart.ipynb +++ b/packages/evo-blockmodels/docs/examples/quickstart.ipynb @@ -17,12 +17,13 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "manager = await ServiceManagerWidget.with_auth_code(\n", " client_id=\"your-client-id\",\n", " cache_location=\"./notebook-data\",\n", - ").login()" + ")\n", + "manager" ] }, { @@ -190,7 +191,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "evo-sdk", "language": "python", "name": "python3" }, diff --git a/packages/evo-colormaps/docs/examples/quickstart.ipynb b/packages/evo-colormaps/docs/examples/quickstart.ipynb index d575e972..0ed9b881 100644 --- a/packages/evo-colormaps/docs/examples/quickstart.ipynb +++ b/packages/evo-colormaps/docs/examples/quickstart.ipynb @@ -17,12 +17,13 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "manager = await ServiceManagerWidget.with_auth_code(\n", " client_id=\"your-client-id\",\n", " cache_location=\"./notebook-data\",\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/packages/evo-compute/docs/examples/quickstart.ipynb b/packages/evo-compute/docs/examples/quickstart.ipynb index 75331903..6061761a 100644 --- a/packages/evo-compute/docs/examples/quickstart.ipynb +++ b/packages/evo-compute/docs/examples/quickstart.ipynb @@ -21,11 +21,12 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "manager = await ServiceManagerWidget.with_auth_code(\n", " client_id=\"your-client-id\", cache_location=\"./notebook-data\"\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/packages/evo-files/docs/examples/quickstart.ipynb b/packages/evo-files/docs/examples/quickstart.ipynb index b60c5c68..1d67284b 100644 --- a/packages/evo-files/docs/examples/quickstart.ipynb +++ b/packages/evo-files/docs/examples/quickstart.ipynb @@ -17,7 +17,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "manager = await ServiceManagerWidget.with_auth_code(\n", " client_id=\"your-client-id\",\n", diff --git a/packages/evo-objects/docs/examples/quickstart.ipynb b/packages/evo-objects/docs/examples/quickstart.ipynb index 5255c1f2..57de7a40 100644 --- a/packages/evo-objects/docs/examples/quickstart.ipynb +++ b/packages/evo-objects/docs/examples/quickstart.ipynb @@ -17,11 +17,12 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "manager = await ServiceManagerWidget.with_auth_code(\n", " client_id=\"your-client-id\", cache_location=\"./notebook-data\"\n", - ").login()" + ")\n", + "manager" ] }, { @@ -182,7 +183,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "# Use the data client to upload all data referenced by the pointset\n", "await data_client.upload_referenced_data(sample_pointset, fb=FeedbackWidget(\"Uploading data\"))\n", @@ -263,7 +264,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "downloaded_object = await object_client.download_object_by_path(\"sdk/v2/sample-pointset.json\")\n", "metadata = downloaded_object.metadata\n", diff --git a/packages/evo-sdk-common/docs/examples/notebook-utilities.ipynb b/packages/evo-sdk-common/docs/examples/notebook-utilities.ipynb index fb403aa9..9846e725 100644 --- a/packages/evo-sdk-common/docs/examples/notebook-utilities.ipynb +++ b/packages/evo-sdk-common/docs/examples/notebook-utilities.ipynb @@ -43,7 +43,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "manager = await ServiceManagerWidget.with_auth_code(\n", " client_id=\"your-client-id\",\n", @@ -113,7 +113,7 @@ "source": [ "import time\n", "\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "fb = FeedbackWidget(\"Loading...\")\n", "\n", diff --git a/packages/evo-sdk-common/src/evo/notebooks/__init__.py b/packages/evo-sdk-common/src/evo/notebooks/__init__.py deleted file mode 100644 index fdd679ba..00000000 --- a/packages/evo-sdk-common/src/evo/notebooks/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright © 2025 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. - -from .widgets import FeedbackWidget, HubSelectorWidget, OrgSelectorWidget, ServiceManagerWidget, WorkspaceSelectorWidget - -__all__ = [ - "FeedbackWidget", - "HubSelectorWidget", - "OrgSelectorWidget", - "ServiceManagerWidget", - "WorkspaceSelectorWidget", -] diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/EvoBadgeCharcoal_FV.png b/packages/evo-sdk-common/src/evo/notebooks/assets/EvoBadgeCharcoal_FV.png deleted file mode 100644 index e7d74099..00000000 Binary files a/packages/evo-sdk-common/src/evo/notebooks/assets/EvoBadgeCharcoal_FV.png and /dev/null differ diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/loading.gif b/packages/evo-sdk-common/src/evo/notebooks/assets/loading.gif deleted file mode 100644 index a718bd89..00000000 Binary files a/packages/evo-sdk-common/src/evo/notebooks/assets/loading.gif and /dev/null differ diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py deleted file mode 100644 index 154f3150..00000000 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ /dev/null @@ -1,491 +0,0 @@ -# Copyright © 2025 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. - -from __future__ import annotations - -import asyncio -import contextlib -from collections.abc import Iterator -from typing import Any, Generic, TypeVar, cast -from uuid import UUID - -import ipywidgets as widgets -from aiohttp.typedefs import StrOrURL -from IPython.display import display - -from evo import logging -from evo.aio import AioTransport -from evo.common import APIConnector, BaseAPIClient, Environment -from evo.common.exceptions import UnauthorizedException -from evo.common.interfaces import IAuthorizer, ICache, IFeedback, ITransport -from evo.discovery import Hub, Organization -from evo.oauth import AnyScopes, EvoScopes, OAuthConnector -from evo.service_manager import ServiceManager -from evo.workspaces import Workspace - -from ._consts import ( - DEFAULT_BASE_URI, - DEFAULT_CACHE_LOCATION, - DEFAULT_DISCOVERY_URL, - DEFAULT_REDIRECT_URL, -) -from ._helpers import FileName, build_button_widget, build_img_widget, init_cache -from .authorizer import AuthorizationCodeAuthorizer -from .env import DotEnv - -T = TypeVar("T") - -logger = logging.getLogger(__name__) - -__all__ = [ - "FeedbackWidget", - "HubSelectorWidget", - "OrgSelectorWidget", - "ServiceManagerWidget", - "WorkspaceSelectorWidget", -] - - -class DropdownSelectorWidget(widgets.HBox, Generic[T]): - UNSELECTED: tuple[str, T] - - 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"), - ) - 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]) - - def _get_options(self) -> list[tuple[str, T]]: - raise NotImplementedError("Subclasses must implement this method.") - - 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" - try: - yield - finally: - self._loading_widget.layout.display = "none" - self.dropdown_widget.disabled = False - - def _update_selected(self, _: dict) -> None: - self.selected = new_value = self.dropdown_widget.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 - selected = self.selected - self.dropdown_widget.options = options = [self.UNSELECTED] + self._get_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] - else: - # Otherwise, ensure the selected option is still valid. - for _, value in options: - if value == selected: - self.selected = new_value = selected - break - else: - # If the selected option is no longer valid, reset to the unselected value. - self.selected = new_value = self.UNSELECTED[1] - - # Make sure the new value is passed to the _on_selected method. - 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 - - @classmethod - def _serialize(cls, value: T) -> str: - raise NotImplementedError("Subclasses must implement this method.") - - @classmethod - def _deserialize(cls, value: str) -> T: - raise NotImplementedError("Subclasses must implement this method.") - - @property - def selected(self) -> T: - value = self._env.get(f"{self.__class__.__name__}.selected", self._serialize(self.UNSELECTED[1])) - return self._deserialize(value) - - @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 - - -_NULL_UUID = UUID(int=0) - - -class _UUIDSelectorWidget(DropdownSelectorWidget[UUID]): - @classmethod - def _serialize(cls, value: UUID) -> str: - return str(value) - - @classmethod - def _deserialize(cls, value: str) -> UUID: - return UUID(value) - - -class OrgSelectorWidget(_UUIDSelectorWidget): - UNSELECTED = ("Select Organisation", _NULL_UUID) - - def __init__(self, env: DotEnv, manager: ServiceManager) -> None: - self._manager = manager - super().__init__("Organisation", env) - - def _get_options(self) -> list[tuple[str, UUID]]: - return [(org.display_name, org.id) for org in self._manager.list_organizations()] - - def _on_selected(self, value: UUID | None) -> None: - self._manager.set_current_organization(value) - - -class HubSelectorWidget(DropdownSelectorWidget[str]): - UNSELECTED = ("Select Hub", "") - - def __init__(self, env: DotEnv, manager: ServiceManager, org_selector: OrgSelectorWidget) -> None: - self._manager = manager - super().__init__("Hub", env) - org_selector.dropdown_widget.observe(self._on_org_selected, names="value") - - def _on_org_selected(self, _: dict) -> None: - self.refresh() - - def _get_options(self) -> list[tuple[str, str]]: - return [(hub.display_name, hub.code) for hub in self._manager.list_hubs()] - - def _on_selected(self, value: str | None) -> None: - self._manager.set_current_hub(value) - - @classmethod - def _serialize(cls, value: str) -> str: - return value - - @classmethod - def _deserialize(cls, value: str) -> str: - return value - - -class WorkspaceSelectorWidget(_UUIDSelectorWidget): - UNSELECTED = ("Select Workspace", _NULL_UUID) - - def __init__(self, env: DotEnv, manager: ServiceManager, hub_selector: HubSelectorWidget) -> None: - self._manager = manager - super().__init__("Workspace", env) - hub_selector.dropdown_widget.observe(self._on_hub_selected, names="value") - - async def refresh_workspaces(self) -> None: - with self._loading(): - await self._manager.refresh_workspaces() - self.refresh() - - def _on_hub_selected(self, _: dict) -> asyncio.Future: - self.disabled = True - return asyncio.ensure_future(self.refresh_workspaces()) - - def _on_selected(self, value: UUID | None) -> None: - self._manager.set_current_workspace(value) - - def _get_options(self) -> list[tuple[str, UUID]]: - return [(ws.display_name, ws.id) for ws in self._manager.list_workspaces()] - - -# Generic type variable for the client factory method. -T_client = TypeVar("T_client", bound=BaseAPIClient) - - -class ServiceManagerWidget(widgets.HBox): - 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. - :param discovery_url: The URL of the Evo Discovery service. - :param cache: The cache to use for storing tokens and other data. - """ - self._authorizer = authorizer - self._service_manager = ServiceManager( - transport=transport, - authorizer=authorizer, - discovery_url=discovery_url, - ) - self._cache = cache - env = DotEnv(cache) - - self._btn = build_button_widget("Sign In") - self._btn.on_click(self._on_click) - self._org_selector = OrgSelectorWidget(env, self._service_manager) - self._hub_selector = HubSelectorWidget(env, self._service_manager, self._org_selector) - self._workspace_selector = WorkspaceSelectorWidget(env, self._service_manager, self._hub_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._hub_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) - - @classmethod - def with_auth_code( - cls, - client_id: str, - base_uri: str = DEFAULT_BASE_URI, - discovery_url: str = DEFAULT_DISCOVERY_URL, - redirect_url: str = DEFAULT_REDIRECT_URL, - client_secret: str | None = None, - cache_location: FileName = DEFAULT_CACHE_LOCATION, - oauth_scopes: AnyScopes = EvoScopes.all_evo | EvoScopes.offline_access, - proxy: StrOrURL | None = None, - ) -> ServiceManagerWidget: - """Create a ServiceManagerWidget with an authorization code authorizer. - - To use it, you will need an OAuth client ID. See the documentation for information on how to obtain this: - https://developer.seequent.com/docs/guides/getting-started/apps-and-tokens - - Chain this method with the login method to authenticate the user and obtain an access token: - - ```python - manager = await ServiceManagerWidget.with_auth_code(client_id="your-client-id").login() - ``` - - :param client_id: The client ID to use for authentication. - :param base_uri: The OAuth server base URI. - :param discovery_url: The URL of the Evo Discovery service. - :param redirect_url: The local URL to redirect the user back to after authorisation. - :param client_secret: The client secret to use for authentication. - :param cache_location: The location of the cache file. - :param oauth_scopes: The OAuth scopes to request. - :param proxy: The proxy URL to use for API requests. - - :returns: The new ServiceManagerWidget. - """ - cache = init_cache(cache_location) - transport = AioTransport(user_agent=client_id, proxy=proxy) - authorizer = AuthorizationCodeAuthorizer( - oauth_connector=OAuthConnector( - transport=transport, - base_uri=base_uri, - client_id=client_id, - client_secret=client_secret, - ), - redirect_url=redirect_url, - scopes=oauth_scopes, - env=DotEnv(cache), - ) - return cls(transport, authorizer, discovery_url, cache) - - async def _login_with_auth_code(self, timeout_seconds: int) -> None: - """Login using an authorization code authorizer. - - This method will attempt to reuse an existing token from the environment file. If no token is found, the user will - be prompted to log in. - - :param timeout_seconds: The number of seconds to wait for the user to log in. - """ - authorizer = cast(AuthorizationCodeAuthorizer, self._authorizer) - if not await authorizer.reuse_token(): - await authorizer.login(timeout_seconds=timeout_seconds) - - async def login(self, timeout_seconds: int = 180) -> ServiceManagerWidget: - """Authenticate the user and obtain an access token. - - Only the notebook authorizer implementations are supported by this method. - - This method returns the current instance of the ServiceManagerWidget to allow for method chaining. - - ```python - manager = await ServiceManagerWidget.with_auth_code(client_id="your-client-id").login() - ``` - - :param timeout_seconds: The maximum time (in seconds) to wait for the authorisation process to complete. - - :returns: The current instance of the ServiceManagerWidget. - """ - # Open the transport without closing it to avoid the overhead of opening it multiple times. - await self._service_manager._transport.open() - with self._loading(): - match self._authorizer: - case AuthorizationCodeAuthorizer(): - await self._login_with_auth_code(timeout_seconds) - case unknown: - raise NotImplementedError(f"ServiceManagerWidget cannot login using {type(unknown).__name__}.") - - # Refresh the services after logging in. - await self.refresh_services() - return self - - @property - def cache(self) -> ICache: - return self._cache - - def _update_btn(self, signed_in: bool) -> None: - if signed_in: - self._btn.description = "Refresh Evo Services" - else: - self._btn.description = "Sign In" - - def _on_click(self, _: widgets.Button) -> asyncio.Future: - return asyncio.ensure_future(self.refresh_services()) - - @contextlib.contextmanager - def _loading(self) -> Iterator[None]: - self._btn.disabled = True - self._loading_widget.layout.display = "flex" - try: - yield - finally: - self._loading_widget.layout.display = "none" - self._btn.disabled = False - - @contextlib.contextmanager - def _loading_services(self) -> Iterator[None]: - self._org_selector.disabled = True - self._hub_selector.disabled = True - self._workspace_selector.disabled = True - try: - yield - finally: - self._org_selector.refresh() - self._hub_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(): - try: - await self._service_manager.refresh_organizations() - except UnauthorizedException: # Expired token or user not logged in. - # Attempt to log in again. - await self.login() - - # Try refresh the services again after logging in. - await self._service_manager.refresh_organizations() - await self._workspace_selector.refresh_workspaces() - self._update_btn(True) - - @property - def organizations(self) -> list[Organization]: - return self._service_manager.list_organizations() - - @property - def hubs(self) -> list[Hub]: - return self._service_manager.list_hubs() - - @property - def workspaces(self) -> list[Workspace]: - return self._service_manager.list_workspaces() - - def get_connector(self) -> APIConnector: - """Get an API connector for the currently selected hub. - - :returns: The API connector. - - :raises SelectionError: If no organization or hub is currently selected. - """ - return self._service_manager.get_connector() - - def get_environment(self) -> Environment: - """Get an environment with the currently selected organization, hub, and workspace. - - :returns: The environment. - - :raises SelectionError: If no organization, hub, or workspace is currently selected. - """ - return self._service_manager.get_environment() - - def create_client(self, client_class: type[T_client], *args: Any, **kwargs: Any) -> T_client: - """Create a client for the currently selected workspace. - - :param client_class: The class of the client to create. - - :returns: The new client. - - :raises SelectionError: If no organization, hub, or workspace is currently selected. - """ - return self._service_manager.create_client(client_class, *args, **kwargs) - - -class FeedbackWidget(IFeedback): - """Simple feedback widget for displaying progress and messages to the user.""" - - 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._last_message = "" - display(self._widget) - - def progress(self, progress: float, message: str | None = None) -> None: - """Progress the feedback and update the text to message. - - This can raise an exception to cancel the current operation. - - :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}%" - if message is not None: - self._msg.value = message diff --git a/packages/evo-widgets/LICENSE.md b/packages/evo-widgets/LICENSE.md new file mode 100644 index 00000000..19ef6928 --- /dev/null +++ b/packages/evo-widgets/LICENSE.md @@ -0,0 +1,190 @@ + Copyright © 2025 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. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/packages/evo-sdk-common/src/evo/notebooks/py.typed b/packages/evo-widgets/README.md similarity index 100% rename from packages/evo-sdk-common/src/evo/notebooks/py.typed rename to packages/evo-widgets/README.md diff --git a/packages/evo-widgets/pyproject.toml b/packages/evo-widgets/pyproject.toml new file mode 100644 index 00000000..167a5c38 --- /dev/null +++ b/packages/evo-widgets/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "evo-widgets" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "anywidget>=0.9.21", + "traitlets>=5.14.3", + "evo-sdk-common>=0.1.0", +] + +[project.optional-dependencies] +aiohttp = [ + "evo-sdk-common[aiohttp]>=0.1.0", +] +notebooks = [ + "evo-sdk-common[notebooks]>=0.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.sources] +evo-sdk-common = { workspace = true } + +[tool.hatch.build.targets.wheel] +packages = ["src/evo"] diff --git a/packages/evo-widgets/src/evo/widgets/__init__.py b/packages/evo-widgets/src/evo/widgets/__init__.py new file mode 100644 index 00000000..0a4fd15a --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/__init__.py @@ -0,0 +1 @@ +from .general import ServiceManagerWidget, FeedbackWidget \ No newline at end of file diff --git a/packages/evo-sdk-common/src/evo/notebooks/_consts.py b/packages/evo-widgets/src/evo/widgets/_consts.py similarity index 100% rename from packages/evo-sdk-common/src/evo/notebooks/_consts.py rename to packages/evo-widgets/src/evo/widgets/_consts.py diff --git a/packages/evo-sdk-common/src/evo/notebooks/_helpers.py b/packages/evo-widgets/src/evo/widgets/_helpers.py similarity index 68% rename from packages/evo-sdk-common/src/evo/notebooks/_helpers.py rename to packages/evo-widgets/src/evo/widgets/_helpers.py index 6611852e..99c3283c 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/_helpers.py +++ b/packages/evo-widgets/src/evo/widgets/_helpers.py @@ -12,11 +12,8 @@ from pathlib import Path from typing import TypeAlias -import ipywidgets as widgets - from evo.common.utils import Cache -from . import assets from ._consts import DEFAULT_CACHE_LOCATION FileName: TypeAlias = str | Path @@ -36,21 +33,3 @@ def init_cache(cache_location: FileName = DEFAULT_CACHE_LOCATION) -> Cache: ignorefile.write_text("*\n") 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 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 diff --git a/packages/evo-sdk-common/src/evo/notebooks/authorizer.py b/packages/evo-widgets/src/evo/widgets/authorizer.py similarity index 100% rename from packages/evo-sdk-common/src/evo/notebooks/authorizer.py rename to packages/evo-widgets/src/evo/widgets/authorizer.py diff --git a/packages/evo-sdk-common/src/evo/notebooks/env.py b/packages/evo-widgets/src/evo/widgets/env.py similarity index 100% rename from packages/evo-sdk-common/src/evo/notebooks/env.py rename to packages/evo-widgets/src/evo/widgets/env.py diff --git a/packages/evo-widgets/src/evo/widgets/general.py b/packages/evo-widgets/src/evo/widgets/general.py new file mode 100644 index 00000000..5a32e303 --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/general.py @@ -0,0 +1,335 @@ +"""ServiceManagerWidget implementation using anywidget.""" + +import asyncio +from pathlib import Path +from typing import Any +from uuid import UUID + +import anywidget +import traitlets + +from evo import logging +from evo.aio import AioTransport +from evo.common import APIConnector, BaseAPIClient, Environment +from evo.common.exceptions import UnauthorizedException +from evo.common.interfaces import IAuthorizer, ICache, IFeedback, ITransport +from evo.discovery import Hub, Organization +from evo.oauth import AnyScopes, EvoScopes, OAuthConnector +from evo.service_manager import ServiceManager +from evo.workspaces import Workspace +from ._helpers import FileName, init_cache + + +from ._consts import ( + DEFAULT_BASE_URI, + DEFAULT_CACHE_LOCATION, + DEFAULT_DISCOVERY_URL, + DEFAULT_REDIRECT_URL, +) +from .authorizer import AuthorizationCodeAuthorizer +from .env import DotEnv + + + + +__all__ = ["ServiceManagerWidget"] + + +class ServiceManagerWidget(anywidget.AnyWidget): + """Interactive widget for managing Evo services authentication and selection.""" + + _esm = Path(__file__).parent / "static" / "service_manager.js" + _css = Path(__file__).parent / "static" / "service_manager.css" + + # Authentication state + signed_in = traitlets.Bool(False).tag(sync=True) + loading = traitlets.Bool(False).tag(sync=True) + + # Dropdown options and selections + organizations = traitlets.List([]).tag(sync=True) + selected_org_id = traitlets.Unicode("").tag(sync=True) + + hubs = traitlets.List([]).tag(sync=True) + selected_hub_code = traitlets.Unicode("").tag(sync=True) + + workspaces = traitlets.List([]).tag(sync=True) + selected_workspace_id = traitlets.Unicode("").tag(sync=True) + + # Button state + button_text = traitlets.Unicode("Sign In").tag(sync=True) + + # Messages from frontend + action = traitlets.Unicode("").tag(sync=True) + + def __init__(self, transport:ITransport, authorizer:IAuthorizer, discovery_url:str, cache:ICache, **kwargs): + """Initialize the ServiceManagerWidget. + + Args: + transport: The transport to use for API requests + authorizer: The authorizer to use for API requests + discovery_url: The URL of the Evo Discovery service + cache: The cache to use for storing tokens and other data + """ + super().__init__(**kwargs) + + self._authorizer = authorizer + self._transport = transport + self._discovery_url = discovery_url + self._cache = cache + self._env = DotEnv(cache) + self._service_manager = None + + # Initialize service manager if dependencies provided + if transport and authorizer and discovery_url: + self._service_manager = ServiceManager( + transport=transport, + authorizer=authorizer, + discovery_url=discovery_url, + ) + + # Observe action changes from frontend + self.observe(self._on_action, names=['action']) + self.observe(self._on_org_change, names=['selected_org_id']) + self.observe(self._on_hub_change, names=['selected_hub_code']) + self.observe(self._on_workspace_change, names=['selected_workspace_id']) + + @classmethod + def with_auth_code( + cls, + client_id: str, + base_uri: str = DEFAULT_BASE_URI, + discovery_url: str = DEFAULT_DISCOVERY_URL, + redirect_url: str = DEFAULT_REDIRECT_URL, + client_secret: str | None = None, + cache_location: str = DEFAULT_CACHE_LOCATION, + oauth_scopes=None, + proxy=None, + ): + """Create a ServiceManagerWidget with an authorization code authorizer. + + Args: + client_id: The client ID to use for authentication + base_uri: The OAuth server base URI + discovery_url: The URL of the Evo Discovery service + redirect_url: The local URL to redirect the user back to after authorisation + client_secret: The client secret to use for authentication + cache_location: The location of the cache file + oauth_scopes: The OAuth scopes to request + proxy: The proxy URL to use for API requests + + Returns: + The new ServiceManagerWidget + """ + + + # Initialize cache + cache = init_cache(cache_location) + ignorefile = cache.root / ".gitignore" + ignorefile.write_text("*\n") + + # Set default scopes if not provided + if oauth_scopes is None: + oauth_scopes = EvoScopes.all_evo | EvoScopes.offline_access + + transport = AioTransport(user_agent=client_id, proxy=proxy) + authorizer = AuthorizationCodeAuthorizer( + oauth_connector=OAuthConnector( + transport=transport, + base_uri=base_uri, + client_id=client_id, + client_secret=client_secret, + ), + redirect_url=redirect_url, + scopes=oauth_scopes, + env=DotEnv(cache), + ) + + return cls(transport, authorizer, discovery_url, cache) + + async def login(self, timeout_seconds: int = 180): + """Authenticate the user and obtain an access token. + + Args: + timeout_seconds: The maximum time (in seconds) to wait for authorisation + + Returns: + The current instance of the ServiceManagerWidget + """ + + + # Open transport + await self._service_manager._transport.open() + + self.loading = True + try: + # Handle authorization + if isinstance(self._authorizer, AuthorizationCodeAuthorizer): + if not await self._authorizer.reuse_token(): + await self._authorizer.login(timeout_seconds=timeout_seconds) + + # Refresh services after login + await self.refresh_services() + + finally: + self.loading = False + + return self + + @property + def cache(self): + """Get the cache instance used by this widget. + + Returns: + The cache instance + """ + return self._cache + + async def refresh_services(self): + """Refresh the list of organizations, hubs, and workspaces.""" + + self.loading = True + try: + try: + await self._service_manager.refresh_organizations() + except UnauthorizedException: + # Re-login if token expired + await self.login() + await self._service_manager.refresh_organizations() + + # Update organizations list + orgs = self._service_manager.list_organizations() + self.organizations = [ + {"id": str(org.id), "name": org.display_name} + for org in orgs + ] + + # Update signed in state and button + self.signed_in = True + self.button_text = "Refresh Evo Services" + + # Refresh hubs and workspaces if already selected + if self.selected_org_id: + self._refresh_hubs() + if self.selected_hub_code: + await self._refresh_workspaces() + + finally: + self.loading = False + + def _refresh_hubs(self): + """Refresh the list of hubs for the selected organization.""" + if self.selected_org_id: + try: + hub_list = self._service_manager.list_hubs() + self.hubs = [ + {"code": hub.code, "name": hub.display_name} + for hub in hub_list + ] + except Exception: + self.hubs = [] + else: + self.hubs = [] + + async def _refresh_workspaces(self): + """Refresh the list of workspaces for the selected hub.""" + if self.selected_hub_code: + try: + await self._service_manager.refresh_workspaces() + ws_list = self._service_manager.list_workspaces() + self.workspaces = [ + {"id": str(ws.id), "name": ws.display_name} + for ws in ws_list + ] + except Exception: + self.workspaces = [] + else: + self.workspaces = [] + + def _on_action(self, change): + """Handle action messages from the frontend.""" + action = change['new'] + if action == 'refresh': + asyncio.create_task(self.refresh_services()) + + def _on_org_change(self, change): + """Handle organization selection changes.""" + org_id = change['new'] + if org_id and self._service_manager: + try: + uuid_org_id = UUID(org_id) if org_id else None + self._service_manager.set_current_organization(uuid_org_id) + self._refresh_hubs() + except Exception: + pass + + def _on_hub_change(self, change): + """Handle hub selection changes.""" + hub_code = change['new'] + if self._service_manager: + self._service_manager.set_current_hub(hub_code if hub_code else None) + asyncio.create_task(self._refresh_workspaces()) + + def _on_workspace_change(self, change): + """Handle workspace selection changes.""" + workspace_id = change['new'] + if workspace_id and self._service_manager: + try: + uuid_ws_id = UUID(workspace_id) if workspace_id else None + self._service_manager.set_current_workspace(uuid_ws_id) + except Exception: + pass + + def get_connector(self): + """Get an API connector for the currently selected hub.""" + return self._service_manager.get_connector() + + def get_environment(self): + """Get an environment with the currently selected organization, hub, and workspace.""" + return self._service_manager.get_environment() + + def create_client(self, client_class, *args, **kwargs): + """Create a client for the currently selected workspace.""" + return self._service_manager.create_client(client_class, *args, **kwargs) + + + +class FeedbackWidget(anywidget.AnyWidget): + """Simple feedback widget for displaying progress and messages to the user.""" + + _esm = Path(__file__).parent / "static" / "feedback.js" + _css = Path(__file__).parent / "static" / "feedback.css" + + # Widget state + label = traitlets.Unicode("").tag(sync=True) + progress_value = traitlets.Float(0.0).tag(sync=True) + progress_percent = traitlets.Unicode("0.0%").tag(sync=True) + message = traitlets.Unicode("").tag(sync=True) + + def __init__(self, label: str, **kwargs): + """Initialize the FeedbackWidget. + + Args: + label: The label for the feedback widget + """ + super().__init__(**kwargs) + self.label = label + self._last_message = "" + + def progress(self, progress: float, message: str | None = None) -> None: + """Update the progress and optional message. + + This can raise an exception to cancel the current operation. + + Args: + progress: A float between 0 and 1 representing the progress (0-100%) + message: An optional message to display to the user + """ + # Clamp progress between 0 and 1 + progress = max(0.0, min(1.0, progress)) + + self.progress_value = progress + self.progress_percent = f"{progress * 100:5.1f}%" + + if message is not None: + self.message = message + self._last_message = message diff --git a/packages/evo-sdk-common/src/evo/notebooks/assets/__init__.py b/packages/evo-widgets/src/evo/widgets/static/__init__.py similarity index 87% rename from packages/evo-sdk-common/src/evo/notebooks/assets/__init__.py rename to packages/evo-widgets/src/evo/widgets/static/__init__.py index ec985302..445cffea 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/assets/__init__.py +++ b/packages/evo-widgets/src/evo/widgets/static/__init__.py @@ -12,14 +12,14 @@ from importlib.abc import Traversable from importlib.resources import files -_ROOT = files(__name__) +_IMAGES = files(__name__) / "images" def get(filename: str) -> Traversable: - """Get the path to a file in this directory. + """Get the path to a file in the images directory. :param filename: The name of the file. :return: A Traversable object representing the file. """ - return _ROOT / filename + return _IMAGES / filename diff --git a/packages/evo-widgets/src/evo/widgets/static/feedback.css b/packages/evo-widgets/src/evo/widgets/static/feedback.css new file mode 100644 index 00000000..32fe2a2e --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/static/feedback.css @@ -0,0 +1,83 @@ +:root { + --evo-primary: #265C7F; + --evo-primary-hover: #1e4a63; + --evo-primary-light: #3a7ba3; + --evo-secondary: #2C3E50; + --evo-accent: #00A9E0; + --evo-success: #10b981; + --evo-success-dark: #047857; + --evo-error: #dc2626; + --evo-error-light: #f87171; + --evo-error-bg: #fef2f2; + --evo-error-bg-dark: #2d1616; + --evo-text-primary: #333; + --evo-text-secondary: #555; + --evo-text-tertiary: #666; + --evo-text-muted: #9ca3af; + --evo-text-dark: #6b7280; + --evo-border: #ccc; + --evo-border-light: #e1e4e8; + --evo-bg-disabled: #f5f5f5; + --evo-bg-hover: #f6f8fa; + --evo-bg-progress: #f0f0f0; + --evo-bg-highlight: #e3f2fd; +} +.feedback-container { + display: flex; + align-items: center; + gap: 15px; + padding: 5px; +} + +.feedback-container, +.feedback-label, +.progress-text, +.feedback-message { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.feedback-label { + font-size: 14px; + font-weight: 500; + color: var(--evo-text-primary); + white-space: nowrap; +} + +.progress-container { + position: relative; + display: flex; + align-items: center; + gap: 10px; +} + +.progress-bar { + width: 400px; + height: 24px; + background-color: var(--evo-bg-progress); + border: 1px solid var(--evo-border); + border-radius: 2px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background-color: var(--evo-primary); + transition: width 0.3s ease; + border-radius:2px; +} + +.progress-text { + min-width: 55px; + font-size: 13px; + font-weight: 500; + color: var(--evo-text-secondary); + text-align: right; +} + +.feedback-message { + font-size: 13px; + font-style: italic; + color: var(--evo-text-tertiary); + flex: 1; +} diff --git a/packages/evo-widgets/src/evo/widgets/static/feedback.js b/packages/evo-widgets/src/evo/widgets/static/feedback.js new file mode 100644 index 00000000..cd502997 --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/static/feedback.js @@ -0,0 +1,73 @@ +function render({ model, el }) { + // Create main container + const container = document.createElement("div"); + container.className = "feedback-container"; + + // Create label + const labelEl = document.createElement("div"); + labelEl.className = "feedback-label"; + labelEl.textContent = model.get("label"); + + // Create progress bar container + const progressContainer = document.createElement("div"); + progressContainer.className = "progress-container"; + + // Create progress bar background + const progressBar = document.createElement("div"); + progressBar.className = "progress-bar"; + + // Create progress bar fill + const progressFill = document.createElement("div"); + progressFill.className = "progress-fill"; + + // Create progress percentage text + const progressText = document.createElement("div"); + progressText.className = "progress-text"; + progressText.textContent = model.get("progress_percent"); + + progressBar.appendChild(progressFill); + progressContainer.appendChild(progressBar); + progressContainer.appendChild(progressText); + + // Create message label + const messageEl = document.createElement("div"); + messageEl.className = "feedback-message"; + messageEl.textContent = model.get("message"); + + // Assemble the widget + container.appendChild(labelEl); + container.appendChild(progressContainer); + container.appendChild(messageEl); + + el.appendChild(container); + + // Update progress bar + const updateProgress = () => { + const progress = model.get("progress_value"); + const percent = model.get("progress_percent"); + progressFill.style.width = `${progress * 100}%`; + progressText.textContent = percent; + }; + + // Update message + const updateMessage = () => { + messageEl.textContent = model.get("message"); + }; + + // Update label + const updateLabel = () => { + labelEl.textContent = model.get("label"); + }; + + // Initialize + updateProgress(); + updateMessage(); + + // Listen to model changes + model.on("change:progress_value", updateProgress); + model.on("change:progress_percent", updateProgress); + model.on("change:message", updateMessage); + model.on("change:label", updateLabel); +} + +export default { render }; diff --git a/packages/evo-widgets/src/evo/widgets/static/images/__init__.py b/packages/evo-widgets/src/evo/widgets/static/images/__init__.py new file mode 100644 index 00000000..a11b4fef --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/static/images/__init__.py @@ -0,0 +1 @@ +# Images directory diff --git a/packages/evo-widgets/src/evo/widgets/static/service_manager.css b/packages/evo-widgets/src/evo/widgets/static/service_manager.css new file mode 100644 index 00000000..1c14df5c --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/static/service_manager.css @@ -0,0 +1,175 @@ +/* Service Manager Widget Styles */ + +/* Seequent EVO Theme - Inline for anywidget compatibility */ +:root { + --evo-primary: #265C7F; + --evo-primary-hover: #1e4a63; + --evo-primary-light: #3a7ba3; + --evo-secondary: #2C3E50; + --evo-accent: #00A9E0; + --evo-success: #10b981; + --evo-success-dark: #047857; + --evo-error: #dc2626; + --evo-error-light: #f87171; + --evo-error-bg: #fef2f2; + --evo-error-bg-dark: #2d1616; + --evo-text-primary: #333; + --evo-text-secondary: #555; + --evo-text-tertiary: #666; + --evo-text-muted: #9ca3af; + --evo-text-dark: #6b7280; + --evo-border: #ccc; + --evo-border-light: #e1e4e8; + --evo-bg-disabled: #f5f5f5; + --evo-bg-hover: #f6f8fa; + --evo-bg-progress: #f0f0f0; + --evo-bg-highlight: #e3f2fd; +} + +.evo-button { + background-color: var(--evo-primary); + color: white; + border: none; + padding: 6px 12px; + border-radius: 0; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.evo-button:hover:not(:disabled) { + background-color: var(--evo-primary-hover); +} + +.evo-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.evo-spinner { + width: 25px; + aspect-ratio: 1; + border-radius: 50%; + border: 4px solid var(--evo-primary); + animation: + l20-1 0.8s infinite linear alternate, + l20-2 1.6s infinite linear; +} + +@keyframes l20-1 { + 0% {clip-path: polygon(50% 50%,0 0, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0% )} + 12.5% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0% )} + 25% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 100% 100%, 100% 100% )} + 50% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )} + 62.5% {clip-path: polygon(50% 50%,100% 0, 100% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )} + 75% {clip-path: polygon(50% 50%,100% 100%, 100% 100%, 100% 100%, 100% 100%, 50% 100%, 0% 100% )} + 100% {clip-path: polygon(50% 50%,50% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 0% 100% )} +} + +@keyframes l20-2 { + 0% {transform:scaleY(1) rotate(0deg)} + 49.99%{transform:scaleY(1) rotate(135deg)} + 50% {transform:scaleY(-1) rotate(0deg)} + 100% {transform:scaleY(-1) rotate(-135deg)} +} + +.evo-dropdown { + padding: 6px 10px; + border: 1px solid var(--evo-border); + border-radius: 1x; + font-size: 13px; + background-color: white; + cursor: pointer; +} + +.evo-dropdown:disabled { + background-color: var(--evo-bg-disabled); + cursor: not-allowed; + opacity: 0.7; +} + +.evo-dropdown:focus { + outline: none; + border-color: var(--evo-primary); + box-shadow: 0 0 0 2px rgba(38, 92, 127, 0.1); +} + +.evo-label { + font-weight: 500; + font-size: 13px; + color: var(--evo-text-primary); +} + +/* Service Manager Specific Styles */ + +.service-manager-container { + padding: 10px; +} + +.service-manager-container, +.sign-in-button, +.selector-label, +.selector-dropdown { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.main-layout { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 20px; +} + +.left-column { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 400px; +} + +.right-column { + flex: 1; +} + +.header-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.evo-logo { + padding: 5px; + border-radius: 4px; +} + +.sign-in-button { + margin: 5px; +} + +.loading-spinner { + margin: 3px; + align-self: center; +} + +.selector-row { + display: flex; + align-items: center; + gap: 10px; + margin: 5px 5px; +} + +.selector-label { + min-width: 90px; +} + +.selector-dropdown { + flex: 1; + min-width: 200px; +} + +.selector-dropdown option { + padding: 5px; +} diff --git a/packages/evo-widgets/src/evo/widgets/static/service_manager.js b/packages/evo-widgets/src/evo/widgets/static/service_manager.js new file mode 100644 index 00000000..348f7e81 --- /dev/null +++ b/packages/evo-widgets/src/evo/widgets/static/service_manager.js @@ -0,0 +1,189 @@ +function render({ model, el }) { + // Create main container + const container = document.createElement("div"); + container.className = "service-manager-container"; + + // Create main layout (left column and right column) + const mainLayout = document.createElement("div"); + mainLayout.className = "main-layout"; + + // Left column - controls + const leftColumn = document.createElement("div"); + leftColumn.className = "left-column"; + + // Header row with logo, button, and loading indicator + const headerRow = document.createElement("div"); + headerRow.className = "header-row"; + + // Logo + const logo = document.createElement("div"); + logo.className = "evo-logo"; + logo.textContent = "EVO"; + headerRow.appendChild(logo); + + // Sign in button + const signInBtn = document.createElement("button"); + signInBtn.className = "sign-in-button evo-button"; + signInBtn.textContent = model.get("button_text"); + signInBtn.onclick = () => { + model.set("action", "refresh"); + model.save_changes(); + }; + headerRow.appendChild(signInBtn); + + // Loading spinner + const loadingSpinner = document.createElement("div"); + loadingSpinner.className = "loading-spinner evo-spinner"; + loadingSpinner.style.display = model.get("loading") ? "block" : "none"; + headerRow.appendChild(loadingSpinner); + + leftColumn.appendChild(headerRow); + + // Organization selector + const orgRow = document.createElement("div"); + orgRow.className = "selector-row"; + + const orgLabel = document.createElement("label"); + orgLabel.textContent = "Organisation:"; + orgLabel.className = "selector-label evo-label"; + + const orgSelect = document.createElement("select"); + orgSelect.className = "selector-dropdown evo-dropdown"; + orgSelect.innerHTML = ''; + + const updateOrgs = () => { + const orgs = model.get("organizations"); + const selectedId = model.get("selected_org_id"); + orgSelect.innerHTML = ''; + orgs.forEach(org => { + const option = document.createElement("option"); + option.value = org.id; + option.textContent = org.name; + if (org.id === selectedId) { + option.selected = true; + } + orgSelect.appendChild(option); + }); + orgSelect.disabled = orgs.length === 0; + }; + + orgSelect.onchange = () => { + model.set("selected_org_id", orgSelect.value); + model.save_changes(); + }; + + orgRow.appendChild(orgLabel); + orgRow.appendChild(orgSelect); + leftColumn.appendChild(orgRow); + + // Hub selector + const hubRow = document.createElement("div"); + hubRow.className = "selector-row"; + + const hubLabel = document.createElement("label"); + hubLabel.textContent = "Hub:"; + hubLabel.className = "selector-label evo-label"; + + const hubSelect = document.createElement("select"); + hubSelect.className = "selector-dropdown evo-dropdown"; + hubSelect.innerHTML = ''; + + const updateHubs = () => { + const hubs = model.get("hubs"); + const selectedCode = model.get("selected_hub_code"); + hubSelect.innerHTML = ''; + hubs.forEach(hub => { + const option = document.createElement("option"); + option.value = hub.code; + option.textContent = hub.name; + if (hub.code === selectedCode) { + option.selected = true; + } + hubSelect.appendChild(option); + }); + hubSelect.disabled = hubs.length === 0; + }; + + hubSelect.onchange = () => { + model.set("selected_hub_code", hubSelect.value); + model.save_changes(); + }; + + hubRow.appendChild(hubLabel); + hubRow.appendChild(hubSelect); + leftColumn.appendChild(hubRow); + + // Workspace selector + const workspaceRow = document.createElement("div"); + workspaceRow.className = "selector-row"; + + const workspaceLabel = document.createElement("label"); + workspaceLabel.textContent = "Workspace:"; + workspaceLabel.className = "selector-label evo-label"; + + const workspaceSelect = document.createElement("select"); + workspaceSelect.className = "selector-dropdown evo-dropdown"; + workspaceSelect.innerHTML = ''; + + const updateWorkspaces = () => { + const workspaces = model.get("workspaces"); + const selectedId = model.get("selected_workspace_id"); + workspaceSelect.innerHTML = ''; + workspaces.forEach(ws => { + const option = document.createElement("option"); + option.value = ws.id; + option.textContent = ws.name; + if (ws.id === selectedId) { + option.selected = true; + } + workspaceSelect.appendChild(option); + }); + workspaceSelect.disabled = workspaces.length === 0; + }; + + workspaceSelect.onchange = () => { + model.set("selected_workspace_id", workspaceSelect.value); + model.save_changes(); + }; + + workspaceRow.appendChild(workspaceLabel); + workspaceRow.appendChild(workspaceSelect); + leftColumn.appendChild(workspaceRow); + + // Right column (for future prompt area) + const rightColumn = document.createElement("div"); + rightColumn.className = "right-column"; + + mainLayout.appendChild(leftColumn); + mainLayout.appendChild(rightColumn); + container.appendChild(mainLayout); + + // Add to DOM + el.appendChild(container); + + // Initialize + updateOrgs(); + updateHubs(); + updateWorkspaces(); + + // Listen to model changes + model.on("change:organizations", updateOrgs); + model.on("change:hubs", updateHubs); + model.on("change:workspaces", updateWorkspaces); + + model.on("change:button_text", () => { + signInBtn.textContent = model.get("button_text"); + }); + + model.on("change:loading", () => { + const loading = model.get("loading"); + loadingSpinner.style.display = loading ? "block" : "none"; + signInBtn.disabled = loading; + }); + + model.on("change:selected_org_id", updateOrgs); + model.on("change:selected_hub_code", updateHubs); + model.on("change:selected_workspace_id", updateWorkspaces); +} + +export default { render }; diff --git a/pyproject.toml b/pyproject.toml index c8fb7df0..79636a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "evo-files[aiohttp,notebooks]", "evo-colormaps[aiohttp,notebooks]", "evo-compute[aiohttp,notebooks]", + "evo-widgets[aiohttp, notebooks]", "jupyter", ] dynamic = ["readme"] @@ -53,6 +54,7 @@ evo-colormaps = { workspace = true } evo-files = { workspace = true } evo-objects = { workspace = true } evo-compute = { workspace = true } +evo-widgets = { workspace = true } samples = { path = "samples" } evo-blockmodels = { workspace = true } diff --git a/samples/auth-and-evo-discovery/native-app-token.ipynb b/samples/auth-and-evo-discovery/native-app-token.ipynb index bde4913c..317fac85 100644 --- a/samples/auth-and-evo-discovery/native-app-token.ipynb +++ b/samples/auth-and-evo-discovery/native-app-token.ipynb @@ -33,7 +33,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"cache\"\n", "\n", @@ -41,7 +41,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/blockmodels/api-examples.ipynb b/samples/blockmodels/api-examples.ipynb index 41f007d4..c3d04f43 100644 --- a/samples/blockmodels/api-examples.ipynb +++ b/samples/blockmodels/api-examples.ipynb @@ -33,7 +33,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"./notebook-data\"\n", "\n", @@ -46,7 +46,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/blockmodels/sdk-examples.ipynb b/samples/blockmodels/sdk-examples.ipynb index f645f4e2..d37a281f 100644 --- a/samples/blockmodels/sdk-examples.ipynb +++ b/samples/blockmodels/sdk-examples.ipynb @@ -35,7 +35,7 @@ "from datetime import datetime\n", "\n", "from evo.blockmodels import BlockModelAPIClient\n", - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"./notebook-data\"\n", "input_path = f\"{cache_location}/input\"\n", @@ -49,7 +49,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/colormaps/api-examples.ipynb b/samples/colormaps/api-examples.ipynb index 5956dd15..e12edc45 100644 --- a/samples/colormaps/api-examples.ipynb +++ b/samples/colormaps/api-examples.ipynb @@ -34,7 +34,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"./notebook-data\"\n", "\n", @@ -47,7 +47,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/colormaps/sdk-examples.ipynb b/samples/colormaps/sdk-examples.ipynb index 477d667d..ee81593a 100644 --- a/samples/colormaps/sdk-examples.ipynb +++ b/samples/colormaps/sdk-examples.ipynb @@ -29,7 +29,7 @@ "outputs": [], "source": [ "from evo.colormaps import ColormapAPIClient\n", - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "from evo.objects import ObjectAPIClient\n", "\n", "cache_location = \"./notebook-data\"\n", @@ -43,7 +43,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/geoscience-objects/download-drilling-campaign/download-drilling-campaign.ipynb b/samples/geoscience-objects/download-drilling-campaign/download-drilling-campaign.ipynb index cf95c0a7..19e651dc 100644 --- a/samples/geoscience-objects/download-drilling-campaign/download-drilling-campaign.ipynb +++ b/samples/geoscience-objects/download-drilling-campaign/download-drilling-campaign.ipynb @@ -32,7 +32,7 @@ "\n", "import pandas as pd\n", "\n", - "from evo.notebooks import FeedbackWidget, ServiceManagerWidget\n", + "from evo.widgets import FeedbackWidget, ServiceManagerWidget\n", "from evo.objects import ObjectAPIClient\n", "\n", "cache_location = \"data\"\n", @@ -46,7 +46,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/geoscience-objects/download-pointset/download-pointset.ipynb b/samples/geoscience-objects/download-pointset/download-pointset.ipynb index df6bceec..c9678f3e 100644 --- a/samples/geoscience-objects/download-pointset/download-pointset.ipynb +++ b/samples/geoscience-objects/download-pointset/download-pointset.ipynb @@ -28,7 +28,7 @@ "source": [ "import pandas as pd\n", "\n", - "from evo.notebooks import FeedbackWidget, ServiceManagerWidget\n", + "from evo.widgets import FeedbackWidget, ServiceManagerWidget\n", "from evo.objects import ObjectAPIClient\n", "\n", "cache_location = \"data\"\n", @@ -42,7 +42,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { diff --git a/samples/geoscience-objects/publish-downhole-collection/publish-downhole-collection.ipynb b/samples/geoscience-objects/publish-downhole-collection/publish-downhole-collection.ipynb index 36c01eae..96584411 100644 --- a/samples/geoscience-objects/publish-downhole-collection/publish-downhole-collection.ipynb +++ b/samples/geoscience-objects/publish-downhole-collection/publish-downhole-collection.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"data\"\n", "input_path = f\"{cache_location}/input\"\n", @@ -40,7 +40,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { @@ -1202,7 +1203,7 @@ "source": [ "from evo_schemas.objects import DownholeCollection_V1_2_0\n", "\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "# Lastly, assemble the complete geoscience object by combining all previously defined components.\n", "# - The name and UUID are used to identify the object.\n", diff --git a/samples/geoscience-objects/publish-drilling-campaign/publish-drilling-campaign.ipynb b/samples/geoscience-objects/publish-drilling-campaign/publish-drilling-campaign.ipynb index 39de0521..c55dd954 100644 --- a/samples/geoscience-objects/publish-drilling-campaign/publish-drilling-campaign.ipynb +++ b/samples/geoscience-objects/publish-drilling-campaign/publish-drilling-campaign.ipynb @@ -28,7 +28,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"data\"\n", "input_path = f\"{cache_location}/input\"\n", @@ -42,7 +42,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { @@ -627,7 +628,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "await data_client.upload_referenced_data(dc.as_dict(), FeedbackWidget(\"Uploading data\"))\n", "new_drilling_campaign_metadata = await object_client.create_geoscience_object(full_obj_path, dc.as_dict())" diff --git a/samples/geoscience-objects/publish-pointset/publish-pointset.ipynb b/samples/geoscience-objects/publish-pointset/publish-pointset.ipynb index dd897526..f4b02a84 100644 --- a/samples/geoscience-objects/publish-pointset/publish-pointset.ipynb +++ b/samples/geoscience-objects/publish-pointset/publish-pointset.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"data\"\n", "input_path = f\"{cache_location}/input\"\n", @@ -40,7 +40,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { @@ -333,7 +334,7 @@ "\n", "from evo_schemas.objects import Pointset_V1_2_0\n", "\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "pointset = Pointset_V1_2_0(\n", " name=object_name,\n", diff --git a/samples/geoscience-objects/publish-regular-2d-grid/publish-regular-2d-grid.ipynb b/samples/geoscience-objects/publish-regular-2d-grid/publish-regular-2d-grid.ipynb index c8ba8a39..360b56fd 100644 --- a/samples/geoscience-objects/publish-regular-2d-grid/publish-regular-2d-grid.ipynb +++ b/samples/geoscience-objects/publish-regular-2d-grid/publish-regular-2d-grid.ipynb @@ -32,7 +32,7 @@ "import geosoft.gxpy.gx as gx\n", "from geosoft.gxapi import GXAPIError\n", "\n", - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "# Create a GX context\n", "try:\n", @@ -59,7 +59,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { @@ -458,7 +459,7 @@ "source": [ "from evo_schemas.objects import Regular2DGrid_V1_2_0\n", "\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "# Assemble the complete geoscience object by combining all previously defined components.\n", "# - The name and UUID are used to identify the object.\n", diff --git a/samples/geoscience-objects/publish-triangular-mesh/publish-triangular-mesh.ipynb b/samples/geoscience-objects/publish-triangular-mesh/publish-triangular-mesh.ipynb index d25eba0e..fbce4e16 100644 --- a/samples/geoscience-objects/publish-triangular-mesh/publish-triangular-mesh.ipynb +++ b/samples/geoscience-objects/publish-triangular-mesh/publish-triangular-mesh.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "\n", "cache_location = \"data\"\n", "input_path = f\"{cache_location}/input\"\n", @@ -40,7 +40,8 @@ " redirect_url=redirect_url,\n", " client_id=client_id,\n", " cache_location=cache_location,\n", - ").login()" + ")\n", + "manager" ] }, { @@ -271,7 +272,7 @@ "from evo_schemas.components import Triangles_V1_2_0\n", "from evo_schemas.objects import TriangleMesh_V2_1_0\n", "\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "# Lastly, assemble the complete geoscience object by combining all previously defined components.\n", "# - The name and UUID are used to identify the object.\n", diff --git a/samples/workspaces/bonus/move-objects.ipynb b/samples/workspaces/bonus/move-objects.ipynb index 49649ccb..01e1431c 100644 --- a/samples/workspaces/bonus/move-objects.ipynb +++ b/samples/workspaces/bonus/move-objects.ipynb @@ -7,7 +7,7 @@ "metadata": {}, "outputs": [], "source": [ - "from evo.notebooks import ServiceManagerWidget\n", + "from evo.widgets import ServiceManagerWidget\n", "from evo.objects import ObjectAPIClient\n", "\n", "cache_location = \"data\"\n", @@ -141,7 +141,7 @@ "\n", "from workspace_utils import collect_data_references\n", "\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "# Download the selected object\n", "if selected_id:\n", @@ -209,7 +209,7 @@ "from pathlib import Path\n", "\n", "from evo.common import Environment\n", - "from evo.notebooks import FeedbackWidget\n", + "from evo.widgets import FeedbackWidget\n", "\n", "# Get the target workspace ID and operation\n", "target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n", diff --git a/uv.lock b/uv.lock index 36fe4fcc..78dc1f20 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ members = [ "evo-objects", "evo-sdk", "evo-sdk-common", + "evo-widgets", ] [[package]] @@ -185,6 +186,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "anywidget" +version = "0.9.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipywidgets" }, + { name = "psygnal" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -871,7 +886,7 @@ test = [ [[package]] name = "evo-compute" -version = "0.0.1rc1" +version = "0.0.1rc2" source = { editable = "packages/evo-compute" } dependencies = [ { name = "evo-sdk-common" }, @@ -992,7 +1007,7 @@ test = [ [[package]] name = "evo-objects" -version = "0.3.2" +version = "0.3.3" source = { editable = "packages/evo-objects" } dependencies = [ { name = "evo-sdk-common", extra = ["jmespath"] }, @@ -1071,7 +1086,7 @@ test = [ [[package]] name = "evo-sdk" -version = "0.1.13" +version = "0.1.15" source = { editable = "." } dependencies = [ { name = "evo-blockmodels", extra = ["aiohttp", "notebooks", "pyarrow"] }, @@ -1080,6 +1095,7 @@ dependencies = [ { name = "evo-files", extra = ["aiohttp", "notebooks"] }, { name = "evo-objects", extra = ["aiohttp", "notebooks", "utils"] }, { name = "evo-sdk-common", extra = ["aiohttp", "jmespath", "notebooks"] }, + { name = "evo-widgets", extra = ["aiohttp", "notebooks"] }, { name = "jupyter" }, ] @@ -1109,6 +1125,7 @@ requires-dist = [ { name = "evo-files", extras = ["aiohttp", "notebooks"], editable = "packages/evo-files" }, { name = "evo-objects", extras = ["aiohttp", "notebooks", "utils"], editable = "packages/evo-objects" }, { name = "evo-sdk-common", extras = ["aiohttp", "notebooks", "jmespath"], editable = "packages/evo-sdk-common" }, + { name = "evo-widgets", extras = ["aiohttp", "notebooks"], editable = "packages/evo-widgets" }, { name = "jupyter" }, ] @@ -1132,7 +1149,7 @@ test = [ [[package]] name = "evo-sdk-common" -version = "0.5.8" +version = "0.5.10" source = { editable = "packages/evo-sdk-common" } dependencies = [ { name = "pure-interface" }, @@ -1216,6 +1233,34 @@ test = [ { name = "pytest" }, ] +[[package]] +name = "evo-widgets" +version = "0.1.0" +source = { editable = "packages/evo-widgets" } +dependencies = [ + { name = "anywidget" }, + { name = "evo-sdk-common" }, + { name = "traitlets" }, +] + +[package.optional-dependencies] +aiohttp = [ + { name = "evo-sdk-common", extra = ["aiohttp"] }, +] +notebooks = [ + { name = "evo-sdk-common", extra = ["notebooks"] }, +] + +[package.metadata] +requires-dist = [ + { name = "anywidget", specifier = ">=0.9.21" }, + { name = "evo-sdk-common", editable = "packages/evo-sdk-common" }, + { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'aiohttp'", editable = "packages/evo-sdk-common" }, + { name = "evo-sdk-common", extras = ["notebooks"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, + { name = "traitlets", specifier = ">=5.14.3" }, +] +provides-extras = ["aiohttp", "notebooks"] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -2692,6 +2737,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, ] +[[package]] +name = "psygnal" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/20/70430999aa609adb0601ec0f72bd23790a6e51a80ae6e7dc6621e6c5ee2a/psygnal-0.15.0.tar.gz", hash = "sha256:5534f18e2d1536675e181c6f81cf04f4177b25a9e60fdcf724a25ce5cc195765", size = 124470, upload-time = "2025-10-15T12:05:50.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/91/a65b177c94269fb60eb913d0e8157498ee676901f054f0f04a7f0445b710/psygnal-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c33a022d2bdfa68c71f6fe964fb316b8cff36a936a6075bb14378823b5bd28d", size = 518166, upload-time = "2025-10-15T12:05:11.997Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b7/3ee2a09dd4cce366b6ba5870e5cd3e8563d428254e7371f45d4746bc5389/psygnal-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:27367d0b47866c6d9c47a19ae9c9570c1525f729314b1d864a7d6e052688645e", size = 576372, upload-time = "2025-10-15T12:05:13.91Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4c/42e597b47e64f4e87f5b70f03e027d0d535b1f302897d4409d774d6859fa/psygnal-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bafa232672ae1d0f51873629c38aeed85476b6620803e8daa14edf20716054c", size = 863424, upload-time = "2025-10-15T12:05:15.099Z" }, + { url = "https://files.pythonhosted.org/packages/90/d3/dd08bf4dad38cd418865ed9b2785f640bec68f3e91d4903ac8dda5926408/psygnal-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:45234b9f6f6a793c3df2867f86c5b5223731eda7734768148175268042c6b7b8", size = 872568, upload-time = "2025-10-15T12:05:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/5daabc87e3962bfdc07e6a745aa513fe92779b18cb9c97517423d6dac241/psygnal-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:1fcade907d3385eb3bc97617f51f275dfc5db45f601cc8ef5c2d17b2f9db1d0d", size = 409544, upload-time = "2025-10-15T12:05:18.551Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b7/1979a82f27c32e70b165b3f1282bbfbaf81a3e44ea85a4599487511533a7/psygnal-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d83239961c66f0763c26df121d8028eeb1cdebc3ce2d511836b3424dda591f3", size = 512136, upload-time = "2025-10-15T12:05:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/f1/85/64e1b2cf86e563aca9498842b7a5fb3bbba38ed50d7306278417f687939e/psygnal-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:219550f78512cd274ee11966033843426a85ee333fbfed73d0f7ce1b153c547c", size = 568105, upload-time = "2025-10-15T12:05:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/17/44/744374443b6e30f2ede11eb182d698d97c0bd021d59e472a0f0a4ddccf8e/psygnal-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c29149a5042d79cb9dfb4d7b6b8c624296681b1533d58b7820c0817ffdd81c4", size = 854314, upload-time = "2025-10-15T12:05:23.489Z" }, + { url = "https://files.pythonhosted.org/packages/94/56/782a5da7a3e0fa5019b617c47a963202de37dabb73f2e43b67b8d76bac0a/psygnal-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d4c9762102df30530044c5a44cc591240ff3b89bd67292e10c0b73cd694c84e9", size = 862143, upload-time = "2025-10-15T12:05:25.316Z" }, + { url = "https://files.pythonhosted.org/packages/4a/93/ee50e54c5a8693a6954647da7e2c6a3150c4a37f0760c6e87ac6de3037dc/psygnal-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f50938b3caf07e34ab044c19d4e9280a53ff65492c285ff211285f0a08934c1", size = 414136, upload-time = "2025-10-15T12:05:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6d/f3adf8f66bf12651f35aff13dd4a6c88afffa815ef8b2b7fa60a602a6cd7/psygnal-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:82eb5767f6cba67fa2d034dab9ec94e8eaf465067666dea3e2f832f2c32debc3", size = 522774, upload-time = "2025-10-15T12:05:27.72Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/adc69bd677a2683f931614fdd716034ba5bc238752973bad3a1415b2f015/psygnal-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5dbcc67b2282eebe2e4e55ff9b50dad6b811d4ab698c573a61a725a6296919ba", size = 576015, upload-time = "2025-10-15T12:05:29.423Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/ad35c19f489c563e6655a6ee9509e1af7ee864ae8fe95f04f851a47e141a/psygnal-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d65e2686c19997eb4495974abc972ca1661504e73b8b58b1fb8466baf0c7ae", size = 888755, upload-time = "2025-10-15T12:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/b6/be/0f680df48bf819025ce4f486443471f541c1559e3ad474311f92fb9a8549/psygnal-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed3ff192cdd14956c2f7a0be4635fa72b2eb2773dfc58a6aa8c14926647041f2", size = 880071, upload-time = "2025-10-15T12:05:32.487Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/c16b2e2a657a908d363ba4b1680cb827f152cb680c24a1add720c8bfde36/psygnal-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ed1fd5797df111c9f9b43a1dc01ffb7c76e19ddc9b0de969e0b816034345246", size = 417554, upload-time = "2025-10-15T12:05:33.758Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b0/d4ef27d30e0336e5dd49a145bc5f55ad7e8c2d4403a8cc89827e3dc4e17d/psygnal-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eb11ecb42b4ff9e45d661396399029c41fbd1cfdd5dbd5c31a3f6f52c8fc2b90", size = 521990, upload-time = "2025-10-15T12:05:35.904Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1a/d78fcfa19c06d5ef610054e159ce2d08a0787af8e2ebdf425ba81284ce71/psygnal-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eace624bb6aa7ad42d1c047a2e3a531f68b3bfc63d8b4c3de9dec4cc122bb534", size = 574962, upload-time = "2025-10-15T12:05:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7b/e9a6fa461ef266c5a23485004934b8f08a2a8ddc447802161ea56d9837dd/psygnal-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0172efeb861280bca05673989a4df21624f44344eff20b873d8c9d0edc01350", size = 884958, upload-time = "2025-10-15T12:05:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a3/1c14461602090ae84120ebd4e47f46990c853e61a71716e69a1ce18c3909/psygnal-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c284edb17542dad0114ad2a942799d6526fa72be7d76d078a388469d584d034c", size = 876350, upload-time = "2025-10-15T12:05:40.013Z" }, + { url = "https://files.pythonhosted.org/packages/e3/71/d143b294259a9067cde1a1a5c4025e0a98dff876576a84495e50da7e1316/psygnal-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:c60d36d46c992835608030ff3fa918c06c7f22133391d90500585fef726f5d07", size = 417938, upload-time = "2025-10-15T12:05:41.302Z" }, + { url = "https://files.pythonhosted.org/packages/e9/0f/8f6e5339cdfe9c67b8a4250501b9b4ac488c836e56c9a15f65b4a3c7a1a8/psygnal-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f0125639597d42b8d78fcd61cc306d7ae71a198d8fac83ab64a07742e8bb1ca8", size = 521077, upload-time = "2025-10-15T12:05:42.491Z" }, + { url = "https://files.pythonhosted.org/packages/b6/46/7b93bad30b1df8ca4d5940b8b6ab60913ab26820f53066f37504f328b76b/psygnal-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d3e759e84c9396f4b1f30bf4b5efd83c5fd359745a72df44b639aa0e5e94c51d", size = 574562, upload-time = "2025-10-15T12:05:43.717Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3b8bf8e341029856b9c09f3c115eb84dad1bf03e0fb849bee575cff8ed/psygnal-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:876e2f8b22236c0327e3da75a17e40a550d89efed904c1e9db23acdd4a66504d", size = 888609, upload-time = "2025-10-15T12:05:44.895Z" }, + { url = "https://files.pythonhosted.org/packages/82/48/ff492974866f041debf57148f582c68247bec66cf0e354adef7db808cae3/psygnal-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5108268d08ac176ac6f8a0cad2c76883282d75a14663f806fdf207eb53e38014", size = 880256, upload-time = "2025-10-15T12:05:46.377Z" }, + { url = "https://files.pythonhosted.org/packages/1a/88/aafeeaf8543189e77dac5f833fe6fac1d3f37a62932da445ccd9533e6770/psygnal-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:6034cacebd252776743450be62f25df323f8cb4ed7b01a46fc4dcf540baa64a6", size = 422151, upload-time = "2025-10-15T12:05:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/4c/68/ad28d0c0a089bcd813fc6355a448acf18c897b4ea02d33276b5f740c2a07/psygnal-0.15.0-py3-none-any.whl", hash = "sha256:023c361c38e8ada87d0704704e1f2b7e799e9771e00b8e174fb409ff9ddeb502", size = 91027, upload-time = "2025-10-15T12:05:49.179Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0"