From 3faec76a7e75b93522c3ba8d7096060449c89c69 Mon Sep 17 00:00:00 2001 From: eric daniels Date: Tue, 6 Oct 2026 06:18:50 -0600 Subject: [PATCH 1/8] add `.with_auth_code` auth method to support code playground --- packages/evo-sdk-common/pyproject.toml | 8 + .../src/evo/notebooks/widgets.py | 81 +++++++++- .../evo-sdk-common/src/evo/oauth/__init__.py | 16 +- .../src/evo/oauth/authorizer.py | 3 +- .../src/evo/pyodide/__init__.py | 25 +++ .../src/evo/pyodide/_browser.py | 97 ++++++++++++ .../src/evo/pyodide/transport.py | 148 ++++++++++++++++++ uv.lock | 13 +- 8 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 packages/evo-sdk-common/src/evo/pyodide/__init__.py create mode 100644 packages/evo-sdk-common/src/evo/pyodide/_browser.py create mode 100644 packages/evo-sdk-common/src/evo/pyodide/transport.py diff --git a/packages/evo-sdk-common/pyproject.toml b/packages/evo-sdk-common/pyproject.toml index 4ac0aaeff..5d0c2b626 100644 --- a/packages/evo-sdk-common/pyproject.toml +++ b/packages/evo-sdk-common/pyproject.toml @@ -32,6 +32,14 @@ notebooks = [ "python-dotenv", "Pillow", ] +# For JupyterLite and other Pyodide runtimes, where aiohttp cannot open sockets. The `pyodide` and `js` modules are +# provided by the runtime itself, so they are not listed here. +pyodide = [ + "ipykernel", + "ipython", + "ipywidgets", + "python-dotenv", +] jmespath = [ "jmespath>=1.0.1", ] diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py index 9e0c90d01..8f156925f 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -13,21 +13,21 @@ import asyncio import contextlib +import sys from collections.abc import Iterator -from typing import Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, 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, IContext, IFeedback, ITransport from evo.discovery import Hub, Organization -from evo.oauth import AnyScopes, EvoScopes, OAuthConnector +from evo.oauth import AccessTokenAuthorizer, AnyScopes, EvoScopes, OAuthConnector +from evo.oauth.exceptions import OAuthError from evo.service_manager import ServiceManager from evo.workspaces import Workspace @@ -41,6 +41,9 @@ from .authorizer import AuthorizationCodeAuthorizer from .env import DotEnv +if TYPE_CHECKING: + from aiohttp.typedefs import StrOrURL + T = TypeVar("T") logger = logging.getLogger(__name__) @@ -226,7 +229,7 @@ def __init__(self, transport: ITransport, authorizer: IAuthorizer, discovery_url ) env = DotEnv(cache) - self._btn = build_button_widget("Sign In") + self._btn = build_button_widget("Refresh Evo Services" if self._is_externally_authorized else "Sign In") self._btn.on_click(self._on_click) self._org_selector = OrgSelectorWidget(env, self._service_manager) self._workspace_selector = WorkspaceSelectorWidget(env, self._service_manager, self._org_selector) @@ -291,6 +294,8 @@ def with_auth_code( :returns: The new ServiceManagerWidget. """ + from evo.aio import AioTransport + cache = init_cache(cache_location) transport = AioTransport(user_agent=client_id, proxy=proxy) authorizer = AuthorizationCodeAuthorizer( @@ -306,6 +311,55 @@ def with_auth_code( ) return cls(transport, authorizer, discovery_url, cache) + @classmethod + def with_access_token( + cls, + access_token: str | None = None, + discovery_url: str = DEFAULT_DISCOVERY_URL, + cache_location: FileName = DEFAULT_CACHE_LOCATION, + transport: ITransport | None = None, + user_agent: str = "evo-sdk-common", + ) -> ServiceManagerWidget: + """Create a ServiceManagerWidget from an access token that was issued elsewhere. + + This is intended for hosted environments, such as JupyterLite, where the page hosting the notebook has already + signed the user in. The token is not refreshed, so a new one must be supplied when it expires. + + ```python + manager = await ServiceManagerWidget.with_access_token().login() + ``` + + :param access_token: The access token to authorise requests with. When omitted, the token is read from browser + local storage, which is only possible in a Pyodide runtime. + :param discovery_url: The URL of the Evo Discovery service. + :param cache_location: The location of the cache file. + :param transport: The transport to use for API requests. Defaults to the browser `fetch` transport in a Pyodide + runtime, and to `AioTransport` elsewhere. + :param user_agent: The value to provide in the `User-Agent` header. + + :returns: The new ServiceManagerWidget. + """ + in_pyodide = sys.platform == "emscripten" + + if access_token is None: + if not in_pyodide: + raise ValueError("An access token must be provided when not running in a Pyodide runtime.") + from evo.pyodide import get_browser_access_token + + access_token = get_browser_access_token() + + if transport is None: + if in_pyodide: + from evo.pyodide import JsTransport + + transport = JsTransport(user_agent=user_agent) + else: + from evo.aio import AioTransport + + transport = AioTransport(user_agent=user_agent) + + return cls(transport, AccessTokenAuthorizer(access_token), discovery_url, init_cache(cache_location)) + async def _login_with_auth_code(self, timeout_seconds: int) -> None: """Login using an authorization code authorizer. @@ -339,6 +393,8 @@ async def login(self, timeout_seconds: int = 180) -> ServiceManagerWidget: match self._authorizer: case AuthorizationCodeAuthorizer(): await self._login_with_auth_code(timeout_seconds) + case AccessTokenAuthorizer(): + pass # The token was issued elsewhere, so there is nothing to do. case unknown: raise NotImplementedError(f"ServiceManagerWidget cannot login using {type(unknown).__name__}.") @@ -350,8 +406,13 @@ async def login(self, timeout_seconds: int = 180) -> ServiceManagerWidget: def cache(self) -> ICache: return self._cache + @property + def _is_externally_authorized(self) -> bool: + """Whether credentials come from outside the widget, so it cannot initiate a sign in itself.""" + return isinstance(self._authorizer, AccessTokenAuthorizer) + def _update_btn(self, signed_in: bool) -> None: - if signed_in: + if signed_in or self._is_externally_authorized: self._btn.description = "Refresh Evo Services" else: self._btn.description = "Sign In" @@ -392,7 +453,13 @@ async def refresh_services(self) -> None: with self._loading_services(): try: await self._service_manager.refresh_organizations() - except UnauthorizedException: # Expired token or user not logged in. + except UnauthorizedException as exc: # Expired token or user not logged in. + if isinstance(self._authorizer, AccessTokenAuthorizer): + raise OAuthError( + "The access token is no longer valid, and cannot be refreshed from here. Sign in again" + " and create a new ServiceManagerWidget with the new token." + ) from exc + # Attempt to log in again. await self.login() diff --git a/packages/evo-sdk-common/src/evo/oauth/__init__.py b/packages/evo-sdk-common/src/evo/oauth/__init__.py index 34c9fe66b..730ba7025 100644 --- a/packages/evo-sdk-common/src/evo/oauth/__init__.py +++ b/packages/evo-sdk-common/src/evo/oauth/__init__.py @@ -11,6 +11,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any + from .authorizer import ( AccessTokenAuthorizer, AuthorizationCodeAuthorizer, @@ -19,7 +21,9 @@ from .connector import OAuthConnector from .data import AccessToken, AnyScopes, EvoScopes, OAuthScopes, Scopes from .exceptions import OAuthError -from .oauth_redirect_handler import OAuthRedirectHandler + +if TYPE_CHECKING: + from .oauth_redirect_handler import OAuthRedirectHandler __all__ = [ "AccessToken", @@ -34,3 +38,13 @@ "OAuthScopes", "Scopes", ] + + +# OAuthRedirectHandler runs a local web server, so it requires aiohttp. Resolve it on demand so that environments +# without aiohttp (such as Pyodide) can still use the rest of this package. +def __getattr__(name: str) -> Any: + if name == "OAuthRedirectHandler": + from .oauth_redirect_handler import OAuthRedirectHandler + + return OAuthRedirectHandler + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/evo-sdk-common/src/evo/oauth/authorizer.py b/packages/evo-sdk-common/src/evo/oauth/authorizer.py index 91f8324a8..d624a48f8 100644 --- a/packages/evo-sdk-common/src/evo/oauth/authorizer.py +++ b/packages/evo-sdk-common/src/evo/oauth/authorizer.py @@ -21,7 +21,6 @@ from .connector import OAuthConnector from .data import AccessToken, AnyScopes, EvoScopes, Scopes from .exceptions import OAuthError -from .oauth_redirect_handler import OAuthRedirectHandler __all__ = [ "AccessTokenAuthorizer", @@ -187,6 +186,8 @@ async def _handle_login(self, timeout_seconds: int) -> AccessToken: :raises OAuthError: If the user does not authenticate within the timeout. """ + from .oauth_redirect_handler import OAuthRedirectHandler + async with OAuthRedirectHandler(self._connector, self._redirect_url) as handler: return await handler.login(scopes=self._scopes, timeout_seconds=timeout_seconds) diff --git a/packages/evo-sdk-common/src/evo/pyodide/__init__.py b/packages/evo-sdk-common/src/evo/pyodide/__init__.py new file mode 100644 index 000000000..50fa9c597 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/pyodide/__init__.py @@ -0,0 +1,25 @@ +# 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. + +"""Support for running the Evo SDK inside a Pyodide runtime, such as JupyterLite. + +This package may only be imported from a Pyodide runtime. It provides a transport that routes requests through the +browser `fetch` API, and helpers for picking up credentials issued by the hosting page. +""" + +from ._browser import get_browser_access_token, get_browser_config +from .transport import JsTransport + +__all__ = [ + "JsTransport", + "get_browser_access_token", + "get_browser_config", +] diff --git a/packages/evo-sdk-common/src/evo/pyodide/_browser.py b/packages/evo-sdk-common/src/evo/pyodide/_browser.py new file mode 100644 index 000000000..354927768 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/pyodide/_browser.py @@ -0,0 +1,97 @@ +# 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. + +from __future__ import annotations + +import base64 +import json +import time + +from evo.logging import getLogger +from evo.oauth.exceptions import OAuthError + +__all__ = [ + "get_browser_access_token", + "get_browser_config", +] + +logger = getLogger("pyodide.browser") + +DEFAULT_TOKEN_STORAGE_KEY = "accessToken" +DEFAULT_CONFIG_URL = "/config.json" + + +def _is_expired(token: str) -> bool: + """Best-effort expiry check, so an obviously stale token fails fast with a useful message. + + The signature is not verified and opaque tokens are treated as valid; the service remains the authority. + """ + try: + _, payload, _ = token.split(".") + padded = payload + "=" * (-len(payload) % 4) + expires_at = json.loads(base64.urlsafe_b64decode(padded))["exp"] + except Exception: + logger.debug("Could not read an expiry from the access token.", exc_info=True) + return False + return float(expires_at) <= time.time() + + +def get_browser_access_token(storage_key: str = DEFAULT_TOKEN_STORAGE_KEY) -> str: + """Read the access token that the hosting page has placed in browser local storage. + + The hosting page is expected to store a JSON document containing an `access_token` field. + + :param storage_key: The local storage key the hosting page stores the token under. + + :returns: The access token. + + :raises OAuthError: If no usable, unexpired token is available. + """ + try: + from js import localStorage + except ImportError: + raise ImportError("Browser credentials are only available when running in a Pyodide runtime.") + + raw_token = localStorage.getItem(storage_key) + if not raw_token: + raise OAuthError(f"No access token found in browser local storage under {storage_key!r}. Sign in first.") + + try: + access_token = json.loads(raw_token)["access_token"] + except (ValueError, KeyError, TypeError): + raise OAuthError(f"The value stored under {storage_key!r} is not a valid access token document.") + + if not isinstance(access_token, str) or not access_token: + raise OAuthError(f"The value stored under {storage_key!r} does not contain an access token.") + + if _is_expired(access_token): + raise OAuthError("The access token provided by the host page has expired. Sign in again.") + + return access_token + + +async def get_browser_config(config_url: str = DEFAULT_CONFIG_URL) -> dict: + """Read the configuration document published by the hosting page. + + :param config_url: The URL of the configuration document. + + :returns: The parsed configuration, or an empty mapping if it is unavailable. + """ + from pyodide.http import pyfetch + + try: + response = await pyfetch(config_url) + if not response.ok: + raise OAuthError(f"Unexpected status {response.status} fetching {config_url}.") + return await response.json() + except Exception: + logger.debug(f"Could not load host configuration from {config_url}.", exc_info=True) + return {} diff --git a/packages/evo-sdk-common/src/evo/pyodide/transport.py b/packages/evo-sdk-common/src/evo/pyodide/transport.py new file mode 100644 index 000000000..94aa1cea1 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/pyodide/transport.py @@ -0,0 +1,148 @@ +# 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. + +from __future__ import annotations + +try: + from js import FormData + + from pyodide.http import pyfetch +except ImportError: + raise ImportError("JsTransport cannot be used because it is not running in a Pyodide runtime.") + +import contextlib +import json +from types import TracebackType +from urllib.parse import urlencode + +from evo.common import HTTPHeaderDict, HTTPResponse, RequestMethod +from evo.common.exceptions import ClientValueError, TransportError +from evo.common.interfaces import ITransport +from evo.logging import getLogger + +__all__ = ["JsTransport"] + +logger = getLogger("pyodide.transport") + +_FORM_CONTENT_TYPES = ("application/x-www-form-urlencoded", "multipart/form-data") + + +class JsTransport(ITransport): + """An `ITransport` implementation that issues requests through the browser `fetch` API. + + See `evo.common.interfaces.ITransport` for more detail. + + Unlike other transports, this one cannot avoid following redirects: the browser exposes manually handled redirects + as opaque responses with status 0. Redirects are therefore followed, and the responses the SDK relies on are + reconstructed from the final response. + """ + + def __init__(self, user_agent: str | None = None) -> None: + """ + :param user_agent: The value to provide in the `User-Agent` header. + """ + self.user_agent = user_agent or "evo-sdk-common" + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + async def __aenter__(self) -> ITransport: + return self + + async def __aexit__( + self, + exc_type: type[Exception] | None, + exc_val: Exception | None, + exc_tb: TracebackType | None, + ) -> None: + pass + + def _build_body( + self, + headers: dict[str, str], + post_params: list[tuple[str, str | bytes]], + body: object | str | bytes | None, + ) -> object: + content_type = headers.get("Content-Type", "") + if post_params and any(form_type in content_type for form_type in _FORM_CONTENT_TYPES): + if content_type == "application/x-www-form-urlencoded": + return urlencode(post_params) + form_data = FormData.new() + for key, value in post_params: + form_data.append(key, value) + return form_data + + if body is not None and not isinstance(body, (str, bytes)): + headers.setdefault("Content-Type", "application/json") + return json.dumps(body) + + return body + + async def request( + self, + method: RequestMethod, + url: str, + headers: HTTPHeaderDict | None = None, + post_params: list[tuple[str, str | bytes]] | None = None, + body: object | str | bytes | None = None, + request_timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> HTTPResponse: + if post_params is not None and body is not None: + raise ClientValueError(msg="HTTP body and post parameters cannot be used at the same time.") + + try: + request_headers = dict(headers or {}) + request_headers.setdefault("User-Agent", self.user_agent) + request_body = self._build_body(request_headers, post_params or [], body) + + # Browsers often strip headers from HEAD responses due to CORS, but the SDK needs Content-Length and + # Accept-Ranges, so issue a GET and discard the body instead. + is_head = method == RequestMethod.HEAD + actual_method = "GET" if is_head else str(method) + + resp = await pyfetch( + url, + method=actual_method, + headers=request_headers, + body=request_body, + redirect="follow", + ) + resp_headers = HTTPHeaderDict(resp.headers) + + # Fetch hides the original 303 when following a compute task submission redirect. Recreate it from the + # final status URL so the SDK can construct and poll the submitted job. + if method == RequestMethod.POST and resp.redirected and resp.status == 202: + resp_headers["Location"] = str(resp.url) + return HTTPResponse(status=303, data=b"", reason="See Other", headers=resp_headers) + + # Redirect responses carry their information in the Location header and have no body to read. + if 300 <= resp.status < 400: + return HTTPResponse(status=resp.status, data=b"", reason=resp.status_text, headers=resp_headers) + + data = await resp.bytes() + + if is_head: + resp_headers.setdefault("Accept-Ranges", "bytes") + resp_headers.setdefault("Content-Length", str(len(data))) + return HTTPResponse(status=resp.status, data=b"", reason=resp.status_text, headers=resp_headers) + + range_header = request_headers.get("Range") or request_headers.get("range") + if range_header and "Content-Range" not in resp_headers: + with contextlib.suppress(IndexError, ValueError): + start, end = range_header.split("=")[1].split("-") + resp_headers["Content-Range"] = f"bytes {start}-{end}/*" + + return HTTPResponse(status=resp.status, data=data, reason=resp.status_text, headers=resp_headers) + except Exception as e: + raise TransportError(msg="Could not complete HTTP request", caused_by=e) diff --git a/uv.lock b/uv.lock index 5fffa2ff1..20df020fa 100644 --- a/uv.lock +++ b/uv.lock @@ -1303,6 +1303,13 @@ notebooks = [ { name = "pillow" }, { name = "python-dotenv" }, ] +pyodide = [ + { 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 = "python-dotenv" }, +] [package.dev-dependencies] dev = [ @@ -1331,16 +1338,20 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'" }, { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, { name = "ipykernel", marker = "extra == 'notebooks'" }, + { name = "ipykernel", marker = "extra == 'pyodide'" }, { name = "ipython", marker = "extra == 'notebooks'" }, + { name = "ipython", marker = "extra == 'pyodide'" }, { name = "ipywidgets", marker = "extra == 'notebooks'" }, + { name = "ipywidgets", marker = "extra == 'pyodide'" }, { name = "jmespath", marker = "extra == 'jmespath'", specifier = ">=1.0.1" }, { name = "pillow", marker = "extra == 'notebooks'" }, { name = "pure-interface", specifier = ">=8.0.0" }, { name = "pydantic", specifier = ">=2" }, { name = "python-dateutil" }, { name = "python-dotenv", marker = "extra == 'notebooks'" }, + { name = "python-dotenv", marker = "extra == 'pyodide'" }, ] -provides-extras = ["aiohttp", "notebooks", "jmespath"] +provides-extras = ["aiohttp", "notebooks", "pyodide", "jmespath"] [package.metadata.requires-dev] dev = [ From 16842e9afe7fffd531a2b931f707e85d1cf18412 Mon Sep 17 00:00:00 2001 From: eric daniels Date: Tue, 6 Oct 2026 08:48:45 -0600 Subject: [PATCH 2/8] add tests, tweak pyproj, docstrings etc --- packages/evo-sdk-common/pyproject.toml | 6 +- .../evo-sdk-common/src/evo/oauth/__init__.py | 4 +- .../tests/notebooks/__init__.py | 10 + .../notebooks/test_service_manager_widget.py | 187 +++++++++++++++ .../tests/oauth/test_lazy_imports.py | 70 ++++++ .../evo-sdk-common/tests/pyodide/__init__.py | 10 + .../evo-sdk-common/tests/pyodide/_stubs.py | 147 ++++++++++++ .../tests/pyodide/test_browser.py | 125 ++++++++++ .../tests/pyodide/test_transport.py | 218 ++++++++++++++++++ uv.lock | 8 +- 10 files changed, 776 insertions(+), 9 deletions(-) create mode 100644 packages/evo-sdk-common/tests/notebooks/__init__.py create mode 100644 packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py create mode 100644 packages/evo-sdk-common/tests/oauth/test_lazy_imports.py create mode 100644 packages/evo-sdk-common/tests/pyodide/__init__.py create mode 100644 packages/evo-sdk-common/tests/pyodide/_stubs.py create mode 100644 packages/evo-sdk-common/tests/pyodide/test_browser.py create mode 100644 packages/evo-sdk-common/tests/pyodide/test_transport.py diff --git a/packages/evo-sdk-common/pyproject.toml b/packages/evo-sdk-common/pyproject.toml index 5d0c2b626..a45883361 100644 --- a/packages/evo-sdk-common/pyproject.toml +++ b/packages/evo-sdk-common/pyproject.toml @@ -32,8 +32,8 @@ notebooks = [ "python-dotenv", "Pillow", ] -# For JupyterLite and other Pyodide runtimes, where aiohttp cannot open sockets. The `pyodide` and `js` modules are -# provided by the runtime itself, so they are not listed here. +# For JupyterLite and other Pyodide runtimes. aiohttp is omitted because sockets are non-functional under WebAssembly, +# so requests go through the browser instead. The `pyodide` and `js` modules are provided by the runtime itself. pyodide = [ "ipykernel", "ipython", @@ -47,7 +47,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/oauth/__init__.py b/packages/evo-sdk-common/src/evo/oauth/__init__.py index 730ba7025..fc7dd5dbf 100644 --- a/packages/evo-sdk-common/src/evo/oauth/__init__.py +++ b/packages/evo-sdk-common/src/evo/oauth/__init__.py @@ -40,8 +40,8 @@ ] -# OAuthRedirectHandler runs a local web server, so it requires aiohttp. Resolve it on demand so that environments -# without aiohttp (such as Pyodide) can still use the rest of this package. +# OAuthRedirectHandler serves the redirect over a local web server, so it needs the optional aiohttp extra. Resolve +# it on demand so the rest of this package stays usable without it. def __getattr__(name: str) -> Any: if name == "OAuthRedirectHandler": from .oauth_redirect_handler import OAuthRedirectHandler 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_widget.py b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py new file mode 100644 index 000000000..f608cc0f9 --- /dev/null +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -0,0 +1,187 @@ +# 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 json +import tempfile +import unittest +from unittest import mock +from uuid import UUID + +from evo.aio import AioTransport +from evo.common import RequestMethod +from evo.common.exceptions import SelectionError +from evo.common.interfaces import IContext +from evo.common.test_tools import MockResponse, TestTransport +from evo.notebooks import ServiceManagerWidget +from evo.oauth import AccessTokenAuthorizer +from evo.oauth.exceptions import OAuthError + +from ..data import load_test_data + +DISCOVERY_URL = "https://discover.test" +EMPTY_WORKSPACE_LIST = json.dumps( + { + "results": [], + "links": { + "first": "http://first", + "last": "http://last", + "next": None, + "previous": None, + "count": 0, + "total": 0, + }, + } +) + + +class _ServiceManagerWidgetTestCase(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.cache_location = self._tmp.name + + def build_widget(self, transport: TestTransport | None = None) -> ServiceManagerWidget: + return ServiceManagerWidget.with_access_token( + "test-token", + discovery_url=DISCOVERY_URL, + cache_location=self.cache_location, + transport=transport or TestTransport(base_url=DISCOVERY_URL), + ) + + +class TestWithAccessToken(_ServiceManagerWidgetTestCase): + def test_uses_access_token_authorizer(self) -> None: + widget = self.build_widget() + assert isinstance(widget._authorizer, AccessTokenAuthorizer) + + async def test_authorizes_requests_with_the_token(self) -> None: + widget = self.build_widget() + headers = await widget._authorizer.get_default_headers() + assert headers == {"Authorization": "Bearer test-token"} + + def test_is_a_context(self) -> None: + """The playground widget was only duck-typed, so this is the main benefit of unifying the two.""" + assert isinstance(self.build_widget(), IContext) + + def test_honours_an_explicit_transport(self) -> None: + transport = TestTransport(base_url=DISCOVERY_URL) + widget = self.build_widget(transport) + assert widget._service_manager._transport is transport + + def test_defaults_to_aiohttp_outside_pyodide(self) -> None: + widget = ServiceManagerWidget.with_access_token("test-token", cache_location=self.cache_location) + assert isinstance(widget._service_manager._transport, AioTransport) + + def test_requires_a_token_outside_pyodide(self) -> None: + with self.assertRaisesRegex(ValueError, "access token must be provided"): + ServiceManagerWidget.with_access_token(cache_location=self.cache_location) + + def test_reads_the_token_from_the_browser_in_pyodide(self) -> None: + with ( + mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), + mock.patch.dict( + "sys.modules", {"evo.pyodide": mock.MagicMock(get_browser_access_token=lambda: "browser-token")} + ), + ): + widget = self.build_widget() + assert isinstance(widget._authorizer, AccessTokenAuthorizer) + + def test_button_invites_a_refresh_rather_than_a_sign_in(self) -> None: + assert self.build_widget()._btn.description == "Refresh Evo Services" + + def test_button_invites_a_sign_in_for_the_auth_code_flow(self) -> None: + widget = ServiceManagerWidget.with_auth_code(client_id="test", cache_location=self.cache_location) + assert widget._btn.description == "Sign In" + + def test_does_not_persist_the_token(self) -> None: + """Unlike the auth code flow, an externally issued token must not be written to the cache.""" + self.build_widget() + written = [path.read_text() for path in self._tmp_files()] + assert not any("test-token" in content for content in written) + + def _tmp_files(self) -> list: + from pathlib import Path + + return [path for path in Path(self.cache_location).rglob("*") if path.is_file()] + + +class TestLoginWithAccessToken(_ServiceManagerWidgetTestCase): + def setUp(self) -> None: + super().setUp() + self.transport = TestTransport(base_url=DISCOVERY_URL) + self.discovery_data = json.dumps(load_test_data("successful_service_discovery.json")) + self.widget = self.build_widget(self.transport) + + def _respond(self, discovery_status: int = 200) -> None: + async def handler(*, method: RequestMethod, url: str, **kwargs: object) -> MockResponse: + if "discovery" in url: + return MockResponse( + status_code=discovery_status, + content=self.discovery_data if discovery_status == 200 else "", + headers={"Content-Type": "application/json"}, + ) + return MockResponse( + status_code=200, content=EMPTY_WORKSPACE_LIST, headers={"Content-Type": "application/json"} + ) + + self.transport.request.side_effect = handler + + async def test_login_does_not_require_an_interactive_flow(self) -> None: + self._respond() + assert await self.widget.login() is self.widget + + async def test_login_refreshes_the_service_list(self) -> None: + self._respond() + await self.widget.login() + assert self.widget.organizations + + def _select_first_real_org(self): + # The first org in the test data uses the nil UUID, which is also the selector's "unselected" sentinel. + org = next(org for org in self.widget.organizations if org.id != UUID(int=0)) + self.widget._org_selector.dropdown_widget.value = org.id + return org + + async def test_login_selects_an_organization_and_hub(self) -> None: + self._respond() + await self.widget.login() + + org = self._select_first_real_org() + + assert self.widget.get_org_id() == org.id + assert isinstance(self.widget.get_org_id(), UUID) + assert self.widget.hubs + + async def test_get_environment_requires_a_workspace(self) -> None: + self._respond() + await self.widget.login() + self._select_first_real_org() + + with self.assertRaises(SelectionError): + self.widget.get_environment() + + async def test_connector_targets_the_selected_hub(self) -> None: + self._respond() + await self.widget.login() + self._select_first_real_org() + + connector = self.widget.get_connector() + assert connector.base_url.startswith(self.widget.hubs[0].url) + + async def test_expired_token_reports_that_it_cannot_be_refreshed(self) -> None: + """A token issued elsewhere cannot be renewed here, so this must not recurse into login().""" + self._respond(discovery_status=401) + with self.assertRaisesRegex(OAuthError, "cannot be refreshed from here"): + await self.widget.login() + + async def test_unsupported_authorizer_is_rejected(self) -> None: + self.widget._authorizer = mock.Mock() + with self.assertRaises(NotImplementedError): + await self.widget.login() diff --git a/packages/evo-sdk-common/tests/oauth/test_lazy_imports.py b/packages/evo-sdk-common/tests/oauth/test_lazy_imports.py new file mode 100644 index 000000000..2d641df5f --- /dev/null +++ b/packages/evo-sdk-common/tests/oauth/test_lazy_imports.py @@ -0,0 +1,70 @@ +# 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 builtins +import subprocess +import sys +import unittest + +import evo.oauth + + +class TestLazyRedirectHandler(unittest.TestCase): + """`OAuthRedirectHandler` runs a local web server, so it must not drag aiohttp into every import.""" + + def test_redirect_handler_resolves_on_demand(self) -> None: + from evo.oauth.oauth_redirect_handler import OAuthRedirectHandler + + assert evo.oauth.OAuthRedirectHandler is OAuthRedirectHandler + + def test_redirect_handler_is_exported(self) -> None: + assert "OAuthRedirectHandler" in evo.oauth.__all__ + + def test_unknown_attribute_raises(self) -> None: + with self.assertRaises(AttributeError): + evo.oauth.NotAThing + + +class TestImportWithoutAiohttp(unittest.TestCase): + """aiohttp is an optional extra, so importing the notebook widgets must not require it.""" + + def _import_with_aiohttp_blocked(self, module: str) -> subprocess.CompletedProcess: + script = ( + "import builtins\n" + "_real = builtins.__import__\n" + "def _guard(name, *args, **kwargs):\n" + " if name.split('.')[0] == 'aiohttp':\n" + " raise ImportError('aiohttp is unavailable')\n" + " return _real(name, *args, **kwargs)\n" + "builtins.__import__ = _guard\n" + f"import {module}\n" + ) + return subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + + def test_evo_oauth_imports_without_aiohttp(self) -> None: + result = self._import_with_aiohttp_blocked("evo.oauth") + assert result.returncode == 0, result.stderr + + def test_evo_notebooks_imports_without_aiohttp(self) -> None: + result = self._import_with_aiohttp_blocked("evo.notebooks") + assert result.returncode == 0, result.stderr + + def test_aiohttp_is_not_imported_as_a_side_effect(self) -> None: + script = "import evo.notebooks, sys; print('aiohttp' in sys.modules)" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "False" + + def test_guard_actually_blocks_aiohttp(self) -> None: + """Guard against the other tests passing because the import hook is broken.""" + assert builtins.__import__ is not None + result = self._import_with_aiohttp_blocked("evo.aio") + assert result.returncode != 0 diff --git a/packages/evo-sdk-common/tests/pyodide/__init__.py b/packages/evo-sdk-common/tests/pyodide/__init__.py new file mode 100644 index 000000000..bc5bffd31 --- /dev/null +++ b/packages/evo-sdk-common/tests/pyodide/__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/pyodide/_stubs.py b/packages/evo-sdk-common/tests/pyodide/_stubs.py new file mode 100644 index 000000000..675ee3554 --- /dev/null +++ b/packages/evo-sdk-common/tests/pyodide/_stubs.py @@ -0,0 +1,147 @@ +# 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. + +"""Stand-ins for the `js` and `pyodide` modules that only exist inside a Pyodide runtime. + +`evo.pyodide` cannot be imported without them, so `install()` must be called before importing anything from it. +""" + +from __future__ import annotations + +import base64 +import importlib.machinery +import json +import sys +import time +import types +from typing import Any + + +class FakeFormData: + """Stand-in for the browser `FormData` object.""" + + def __init__(self) -> None: + self.fields: list[tuple[str, str | bytes]] = [] + + @classmethod + def new(cls) -> FakeFormData: + return cls() + + def append(self, key: str, value: str | bytes) -> None: + self.fields.append((key, value)) + + +class FakeLocalStorage: + """Stand-in for the browser `localStorage` object.""" + + def __init__(self, items: dict[str, str] | None = None) -> None: + self._items = dict(items or {}) + + def getItem(self, key: str) -> str | None: # noqa: N802 - mirrors the browser API. + return self._items.get(key) + + def setItem(self, key: str, value: str) -> None: # noqa: N802 - mirrors the browser API. + self._items[key] = value + + +class FakeResponse: + """Stand-in for the response object returned by `pyodide.http.pyfetch`.""" + + def __init__( + self, + status: int = 200, + headers: dict[str, str] | None = None, + body: bytes = b"", + url: str = "https://example.test/", + redirected: bool = False, + status_text: str = "OK", + ) -> None: + self.status = status + self.headers = dict(headers or {}) + self.url = url + self.redirected = redirected + self.status_text = status_text + self.body_read = False + self._body = body + + @property + def ok(self) -> bool: + return 200 <= self.status < 300 + + async def bytes(self) -> bytes: + self.body_read = True + return self._body + + async def json(self) -> Any: + self.body_read = True + return json.loads(self._body) + + +class FakeFetch: + """Stand-in for `pyodide.http.pyfetch` that records calls and returns canned responses.""" + + def __init__(self, *responses: FakeResponse, error: Exception | None = None) -> None: + self._responses = list(responses) + self._error = error + self.calls: list[tuple[str, dict[str, Any]]] = [] + + @property + def last_call(self) -> dict[str, Any]: + return self.calls[-1][1] + + async def __call__(self, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append((url, kwargs)) + if self._error is not None: + raise self._error + return self._responses.pop(0) if len(self._responses) > 1 else self._responses[0] + + +def jwt(expires_at: float) -> str: + """Build an unsigned JWT carrying only an `exp` claim.""" + payload = base64.urlsafe_b64encode(json.dumps({"exp": expires_at}).encode()).decode().rstrip("=") + return f"header.{payload}.signature" + + +def valid_jwt() -> str: + return jwt(time.time() + 600) + + +def expired_jwt() -> str: + return jwt(time.time() - 10) + + +def _new_module(name: str) -> types.ModuleType: + module = types.ModuleType(name) + # find_spec() and importlib machinery reject modules without a spec. + module.__spec__ = importlib.machinery.ModuleSpec(name, None) + return module + + +def install() -> None: + """Register the stand-in runtime modules so that `evo.pyodide` can be imported.""" + if "js" not in sys.modules: + js = _new_module("js") + js.FormData = FakeFormData + js.localStorage = FakeLocalStorage() + sys.modules["js"] = js + + if "pyodide" not in sys.modules: + pyodide = _new_module("pyodide") + pyodide.__path__ = [] + http = _new_module("pyodide.http") + + async def pyfetch(*args: Any, **kwargs: Any) -> FakeResponse: + raise AssertionError("pyfetch must be patched by the test.") + + http.pyfetch = pyfetch + pyodide.http = http + sys.modules["pyodide"] = pyodide + sys.modules["pyodide.http"] = http diff --git a/packages/evo-sdk-common/tests/pyodide/test_browser.py b/packages/evo-sdk-common/tests/pyodide/test_browser.py new file mode 100644 index 000000000..c0c6e9c4a --- /dev/null +++ b/packages/evo-sdk-common/tests/pyodide/test_browser.py @@ -0,0 +1,125 @@ +# 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 json +import sys +import unittest +from unittest import mock + +from evo.oauth.exceptions import OAuthError + +from ._stubs import FakeFetch, FakeLocalStorage, FakeResponse, expired_jwt, install, valid_jwt + +install() + +from evo.pyodide import _browser, get_browser_access_token, get_browser_config # noqa: E402 + + +class TestIsExpired(unittest.TestCase): + def test_expired_token(self) -> None: + assert _browser._is_expired(expired_jwt()) + + def test_valid_token(self) -> None: + assert not _browser._is_expired(valid_jwt()) + + def test_opaque_token_is_assumed_valid(self) -> None: + assert not _browser._is_expired("an-opaque-token") + + def test_undecodable_payload_is_assumed_valid(self) -> None: + assert not _browser._is_expired("header.!!!not-base64!!!.signature") + + def test_token_without_exp_claim_is_assumed_valid(self) -> None: + assert not _browser._is_expired("header.e30.signature") # Payload is `{}`. + + +class TestGetBrowserAccessToken(unittest.TestCase): + def set_storage(self, items: dict[str, str]) -> None: + patcher = mock.patch.object(sys.modules["js"], "localStorage", FakeLocalStorage(items)) + patcher.start() + self.addCleanup(patcher.stop) + + def test_returns_stored_token(self) -> None: + token = valid_jwt() + self.set_storage({"accessToken": json.dumps({"access_token": token})}) + assert get_browser_access_token() == token + + def test_reads_custom_storage_key(self) -> None: + token = valid_jwt() + self.set_storage({"evoToken": json.dumps({"access_token": token})}) + assert get_browser_access_token(storage_key="evoToken") == token + + def test_missing_key(self) -> None: + self.set_storage({}) + with self.assertRaisesRegex(OAuthError, "No access token found"): + get_browser_access_token() + + def test_empty_value(self) -> None: + self.set_storage({"accessToken": ""}) + with self.assertRaisesRegex(OAuthError, "No access token found"): + get_browser_access_token() + + def test_value_is_not_json(self) -> None: + self.set_storage({"accessToken": "not json"}) + with self.assertRaisesRegex(OAuthError, "not a valid access token document"): + get_browser_access_token() + + def test_document_is_missing_access_token(self) -> None: + self.set_storage({"accessToken": json.dumps({"refresh_token": "abc"})}) + with self.assertRaisesRegex(OAuthError, "not a valid access token document"): + get_browser_access_token() + + def test_access_token_is_not_a_string(self) -> None: + self.set_storage({"accessToken": json.dumps({"access_token": 1234})}) + with self.assertRaisesRegex(OAuthError, "does not contain an access token"): + get_browser_access_token() + + def test_expired_token(self) -> None: + self.set_storage({"accessToken": json.dumps({"access_token": expired_jwt()})}) + with self.assertRaisesRegex(OAuthError, "has expired"): + get_browser_access_token() + + def test_opaque_token_is_accepted(self) -> None: + self.set_storage({"accessToken": json.dumps({"access_token": "an-opaque-token"})}) + assert get_browser_access_token() == "an-opaque-token" + + +class TestGetBrowserConfig(unittest.IsolatedAsyncioTestCase): + def patch_fetch(self, *responses: FakeResponse, error: Exception | None = None) -> FakeFetch: + fetch = FakeFetch(*responses, error=error) + patcher = mock.patch.object(sys.modules["pyodide.http"], "pyfetch", fetch) + patcher.start() + self.addCleanup(patcher.stop) + return fetch + + async def test_returns_parsed_config(self) -> None: + fetch = self.patch_fetch( + FakeResponse(status=200, body=json.dumps({"discovery_url": "https://d.test"}).encode()) + ) + + assert await get_browser_config() == {"discovery_url": "https://d.test"} + assert fetch.calls[0][0] == "/config.json" + + async def test_reads_custom_url(self) -> None: + fetch = self.patch_fetch(FakeResponse(status=200, body=b"{}")) + await get_browser_config("/custom.json") + assert fetch.calls[0][0] == "/custom.json" + + async def test_missing_config_returns_empty_mapping(self) -> None: + self.patch_fetch(FakeResponse(status=404, status_text="Not Found")) + assert await get_browser_config() == {} + + async def test_unparseable_config_returns_empty_mapping(self) -> None: + self.patch_fetch(FakeResponse(status=200, body=b"not json")) + assert await get_browser_config() == {} + + async def test_fetch_failure_returns_empty_mapping(self) -> None: + self.patch_fetch(error=RuntimeError("network down")) + assert await get_browser_config() == {} diff --git a/packages/evo-sdk-common/tests/pyodide/test_transport.py b/packages/evo-sdk-common/tests/pyodide/test_transport.py new file mode 100644 index 000000000..f1324d7af --- /dev/null +++ b/packages/evo-sdk-common/tests/pyodide/test_transport.py @@ -0,0 +1,218 @@ +# 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 json +import unittest +from unittest import mock + +from evo.common import HTTPHeaderDict, RequestMethod +from evo.common.exceptions import ClientValueError, TransportError +from evo.common.interfaces import ITransport + +from ._stubs import FakeFetch, FakeFormData, FakeResponse, install + +install() + +from evo.pyodide import JsTransport # noqa: E402 +from evo.pyodide import transport as transport_module # noqa: E402 + + +class TestJsTransport(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.transport = JsTransport(user_agent="test-agent") + + def patch_fetch(self, *responses: FakeResponse, error: Exception | None = None) -> FakeFetch: + fetch = FakeFetch(*responses, error=error) + patcher = mock.patch.object(transport_module, "pyfetch", fetch) + patcher.start() + self.addCleanup(patcher.stop) + return fetch + + def test_implements_transport_interface(self) -> None: + assert isinstance(self.transport, ITransport) + + def test_default_user_agent(self) -> None: + assert JsTransport().user_agent == "evo-sdk-common" + + async def test_open_and_close_are_noops(self) -> None: + await self.transport.open() + await self.transport.close() + + async def test_async_context_manager(self) -> None: + async with self.transport as entered: + assert entered is self.transport + + async def test_get_request(self) -> None: + fetch = self.patch_fetch(FakeResponse(status=200, body=b"hello", headers={"Content-Type": "text/plain"})) + + response = await self.transport.request(RequestMethod.GET, "https://example.test/thing") + + assert response.status == 200 + assert response.data == b"hello" + assert response.headers["Content-Type"] == "text/plain" + assert fetch.calls[0][0] == "https://example.test/thing" + assert fetch.last_call["method"] == "GET" + + async def test_adds_default_user_agent(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + await self.transport.request(RequestMethod.GET, "https://example.test/") + assert fetch.last_call["headers"]["User-Agent"] == "test-agent" + + async def test_does_not_override_user_agent(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + await self.transport.request( + RequestMethod.GET, "https://example.test/", headers=HTTPHeaderDict({"User-Agent": "caller"}) + ) + assert fetch.last_call["headers"]["User-Agent"] == "caller" + + async def test_body_and_post_params_are_mutually_exclusive(self) -> None: + self.patch_fetch(FakeResponse()) + with self.assertRaises(ClientValueError): + await self.transport.request( + RequestMethod.POST, "https://example.test/", post_params=[("a", "b")], body={"a": "b"} + ) + + async def test_serialises_object_body_as_json(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + + await self.transport.request(RequestMethod.POST, "https://example.test/", body={"name": "value"}) + + assert json.loads(fetch.last_call["body"]) == {"name": "value"} + assert fetch.last_call["headers"]["Content-Type"] == "application/json" + + async def test_preserves_explicit_content_type_for_object_body(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + + await self.transport.request( + RequestMethod.POST, + "https://example.test/", + headers=HTTPHeaderDict({"Content-Type": "application/vnd.custom+json"}), + body={"name": "value"}, + ) + + assert fetch.last_call["headers"]["Content-Type"] == "application/vnd.custom+json" + + async def test_passes_string_body_through_unchanged(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + await self.transport.request(RequestMethod.POST, "https://example.test/", body="raw") + assert fetch.last_call["body"] == "raw" + + async def test_url_encodes_form_post_params(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + + await self.transport.request( + RequestMethod.POST, + "https://example.test/", + headers=HTTPHeaderDict({"Content-Type": "application/x-www-form-urlencoded"}), + post_params=[("one", "1"), ("two", "2")], + ) + + assert fetch.last_call["body"] == "one=1&two=2" + + async def test_builds_form_data_for_multipart_post_params(self) -> None: + fetch = self.patch_fetch(FakeResponse()) + + await self.transport.request( + RequestMethod.POST, + "https://example.test/", + headers=HTTPHeaderDict({"Content-Type": "multipart/form-data"}), + post_params=[("file", b"bytes"), ("name", "value")], + ) + + body = fetch.last_call["body"] + assert isinstance(body, FakeFormData) + assert body.fields == [("file", b"bytes"), ("name", "value")] + + async def test_head_request_is_sent_as_get_without_body(self) -> None: + response = FakeResponse(status=200, body=b"0123456789") + fetch = self.patch_fetch(response) + + result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") + + assert fetch.last_call["method"] == "GET" + assert result.data == b"" + assert result.headers["Content-Length"] == "10" + assert result.headers["Accept-Ranges"] == "bytes" + + async def test_head_request_keeps_headers_reported_by_the_server(self) -> None: + response = FakeResponse( + status=200, body=b"0123456789", headers={"Content-Length": "99", "Accept-Ranges": "none"} + ) + self.patch_fetch(response) + + result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") + + assert result.headers["Content-Length"] == "99" + assert result.headers["Accept-Ranges"] == "none" + + async def test_redirect_response_has_no_body(self) -> None: + response = FakeResponse(status=302, headers={"Location": "https://example.test/elsewhere"}) + self.patch_fetch(response) + + result = await self.transport.request(RequestMethod.GET, "https://example.test/") + + assert result.status == 302 + assert result.data == b"" + assert result.headers["Location"] == "https://example.test/elsewhere" + assert not response.body_read + + async def test_followed_task_submission_redirect_is_restored_as_303(self) -> None: + response = FakeResponse(status=202, url="https://example.test/tasks/1", redirected=True) + self.patch_fetch(response) + + result = await self.transport.request(RequestMethod.POST, "https://example.test/tasks") + + assert result.status == 303 + assert result.reason == "See Other" + assert result.headers["Location"] == "https://example.test/tasks/1" + + async def test_unredirected_202_is_left_alone(self) -> None: + self.patch_fetch(FakeResponse(status=202, body=b"accepted")) + + result = await self.transport.request(RequestMethod.POST, "https://example.test/tasks") + + assert result.status == 202 + assert result.data == b"accepted" + + async def test_synthesises_content_range_when_hidden_by_cors(self) -> None: + self.patch_fetch(FakeResponse(status=206, body=b"0123")) + + result = await self.transport.request( + RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) + ) + + assert result.headers["Content-Range"] == "bytes 0-3/*" + + async def test_keeps_content_range_reported_by_the_server(self) -> None: + self.patch_fetch(FakeResponse(status=206, body=b"0123", headers={"Content-Range": "bytes 0-3/10"})) + + result = await self.transport.request( + RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) + ) + + assert result.headers["Content-Range"] == "bytes 0-3/10" + + async def test_ignores_malformed_range_header(self) -> None: + self.patch_fetch(FakeResponse(status=206, body=b"0123")) + + result = await self.transport.request( + RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "nonsense"}) + ) + + assert "Content-Range" not in result.headers + + async def test_wraps_fetch_failures_in_transport_error(self) -> None: + self.patch_fetch(error=RuntimeError("network down")) + + with self.assertRaises(TransportError) as ctx: + await self.transport.request(RequestMethod.GET, "https://example.test/") + + assert isinstance(ctx.exception.caused_by, RuntimeError) diff --git a/uv.lock b/uv.lock index 20df020fa..57475110e 100644 --- a/uv.lock +++ b/uv.lock @@ -1315,7 +1315,7 @@ pyodide = [ 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" }, @@ -1327,7 +1327,7 @@ notebooks = [ { name = "jupyter" }, ] test = [ - { name = "evo-sdk-common", extra = ["aiohttp"] }, + { name = "evo-sdk-common", extra = ["aiohttp", "notebooks"] }, { name = "freezegun" }, { name = "parameterized" }, { name = "pytest" }, @@ -1357,8 +1357,8 @@ provides-extras = ["aiohttp", "notebooks", "pyodide", "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" }, @@ -1370,7 +1370,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" }, From d7c0e2275867e53b2247d1b9dce95f43e5808b38 Mon Sep 17 00:00:00 2001 From: eric daniels Date: Wed, 7 Oct 2026 09:57:13 -0600 Subject: [PATCH 3/8] switch to anywidget --- .../workspace_admin_helper.py | 10 +- packages/evo-sdk-common/pyproject.toml | 4 +- .../src/evo/notebooks/_helpers.py | 37 +-- .../src/evo/notebooks/assets/feedback.js | 55 ++++ .../src/evo/notebooks/assets/selector.js | 76 ++++++ .../evo/notebooks/assets/service_manager.js | 96 +++++++ .../src/evo/notebooks/assets/widgets.css | 85 ++++++ .../src/evo/notebooks/widgets.py | 247 +++++++++++------- .../notebooks/test_service_manager_widget.py | 99 ++++++- uv.lock | 61 ++++- 10 files changed, 645 insertions(+), 125 deletions(-) create mode 100644 packages/evo-sdk-common/src/evo/notebooks/assets/feedback.js create mode 100644 packages/evo-sdk-common/src/evo/notebooks/assets/selector.js create mode 100644 packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js create mode 100644 packages/evo-sdk-common/src/evo/notebooks/assets/widgets.css 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 a45883361..35a1d489f 100644 --- a/packages/evo-sdk-common/pyproject.toml +++ b/packages/evo-sdk-common/pyproject.toml @@ -26,18 +26,18 @@ Documentation = "https://developer.seequent.com/" aiohttp = ["aiohttp"] notebooks = [ "evo-sdk-common[aiohttp]", + "anywidget", "ipykernel", "ipython", - "ipywidgets", "python-dotenv", "Pillow", ] # For JupyterLite and other Pyodide runtimes. aiohttp is omitted because sockets are non-functional under WebAssembly, # so requests go through the browser instead. The `pyodide` and `js` modules are provided by the runtime itself. pyodide = [ + "anywidget", "ipykernel", "ipython", - "ipywidgets", "python-dotenv", ] jmespath = [ 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..e2a146fb1 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js @@ -0,0 +1,96 @@ +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(); + + const channelId = model.get("browser_token_channel"); + const tokenChannel = channelId && typeof BroadcastChannel !== "undefined" + ? new BroadcastChannel(`evo-browser-token-${channelId}`) : null; + const sendBrowserToken = (message) => { + if (tokenChannel) { + tokenChannel.postMessage(message); + } else { + model.send(message); + } + }; + + if (model.get("browser_token_required")) { + try { + const stored = localStorage.getItem("accessToken"); + if (!stored) { + throw new Error("No access token found in browser local storage. Sign in first."); + } + const token = JSON.parse(stored)?.access_token; + if (typeof token !== "string" || !token) { + throw new Error("The browser access token document is invalid."); + } + sendBrowserToken({ type: "browser_token", token }); + } catch (error) { + sendBrowserToken({ type: "browser_token", error: error instanceof SyntaxError + ? "The browser access token document is invalid." + : error instanceof Error ? error.message : "Cannot read the browser access token." }); + } + } + + 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); + tokenChannel?.close(); + }; +} + +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 8f156925f..53438b815 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -16,9 +16,10 @@ import sys from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast -from uuid import UUID +from uuid import UUID, uuid4 -import ipywidgets as widgets +import anywidget +import traitlets from IPython.display import display from evo import logging @@ -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 @@ -55,25 +56,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.") @@ -82,23 +92,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] @@ -116,7 +127,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: @@ -134,15 +145,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) @@ -183,7 +186,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(): @@ -205,14 +208,34 @@ 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) + browser_token_required = traitlets.Bool(False).tag(sync=True) + browser_token_channel = traitlets.Unicode("").tag(sync=True) + + def __init__( + self, + transport: ITransport, + authorizer: IAuthorizer, + discovery_url: str, + cache: ICache, + *, + browser_token: bool = False, + ) -> None: """ :param transport: The transport to use for API requests. :param authorizer: The authorizer to use for API requests. @@ -221,6 +244,14 @@ def __init__(self, transport: ITransport, authorizer: IAuthorizer, discovery_url """ self._authorizer = authorizer self._cache = cache + self._browser_token_event = asyncio.Event() if browser_token else None + self._browser_token: str | None = None + self._browser_token_error: str | None = None + self._browser_channel = None + self._browser_channel_listener = None + browser_token_channel = ( + self._open_browser_token_channel() if browser_token and sys.platform == "emscripten" else "" + ) self._service_manager = ServiceManager( transport=transport, authorizer=authorizer, @@ -229,36 +260,39 @@ def __init__(self, transport: ITransport, authorizer: IAuthorizer, discovery_url ) env = DotEnv(cache) - self._btn = build_button_widget("Refresh Evo Services" if self._is_externally_authorized else "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="Refresh Evo Services" if self._is_externally_authorized else "Sign In", + browser_token_required=browser_token, + browser_token_channel=browser_token_channel, + ) + 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" + display(self, self._org_selector, self._workspace_selector) - self._prompt_area = widgets.Output() - self._prompt_area.layout.display = "none" + def _open_browser_token_channel(self) -> str: + from js import BroadcastChannel + from pyodide.ffi import create_proxy - 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]) + channel_id = uuid4().hex + channel = BroadcastChannel.new(f"evo-browser-token-{channel_id}") + listener = create_proxy(lambda message: self._handle_frontend_msg(self, message.data.to_py(), [])) + channel.addEventListener("message", listener) + self._browser_channel = channel + self._browser_channel_listener = listener + return channel_id - super().__init__( - [col_1, col_2], - layout={ - "display": "flex", - "flex_flow": "row", - "justify_content": "space-between", - "align_items": "center", - }, - ) - display(self) + def _close_browser_token_channel(self) -> None: + if self._browser_channel is not None: + self._browser_channel.removeEventListener("message", self._browser_channel_listener) + self._browser_channel.close() + self._browser_channel_listener.destroy() + self._browser_channel = None + self._browser_channel_listener = None @classmethod def with_auth_code( @@ -329,8 +363,8 @@ def with_access_token( manager = await ServiceManagerWidget.with_access_token().login() ``` - :param access_token: The access token to authorise requests with. When omitted, the token is read from browser - local storage, which is only possible in a Pyodide runtime. + :param access_token: The access token to authorise requests with. When omitted in Pyodide, the widget frontend + reads it from the hosting page's local storage during login. :param discovery_url: The URL of the Evo Discovery service. :param cache_location: The location of the cache file. :param transport: The transport to use for API requests. Defaults to the browser `fetch` transport in a Pyodide @@ -344,9 +378,6 @@ def with_access_token( if access_token is None: if not in_pyodide: raise ValueError("An access token must be provided when not running in a Pyodide runtime.") - from evo.pyodide import get_browser_access_token - - access_token = get_browser_access_token() if transport is None: if in_pyodide: @@ -358,7 +389,35 @@ def with_access_token( transport = AioTransport(user_agent=user_agent) - return cls(transport, AccessTokenAuthorizer(access_token), discovery_url, init_cache(cache_location)) + return cls( + transport, + AccessTokenAuthorizer(access_token or ""), + discovery_url, + init_cache(cache_location), + browser_token=access_token is None, + ) + + async def _receive_browser_token(self, timeout_seconds: int) -> None: + event = self._browser_token_event + if event is None: + return + try: + await asyncio.wait_for(event.wait(), timeout_seconds) + except TimeoutError as exc: + raise OAuthError( + "Timed out waiting for the browser access token. Display the widget and try again." + ) from exc + finally: + self._close_browser_token_channel() + if self._browser_token_error: + raise OAuthError(self._browser_token_error) + if not self._browser_token: + raise OAuthError("No access token received from the hosting page. Sign in first.") + + self._authorizer = AccessTokenAuthorizer(self._browser_token) + self._service_manager._authorizer = self._authorizer + self._browser_token = None + self._browser_token_event = None async def _login_with_auth_code(self, timeout_seconds: int) -> None: """Login using an authorization code authorizer. @@ -390,6 +449,7 @@ async def login(self, timeout_seconds: int = 180) -> 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(): + await self._receive_browser_token(timeout_seconds) match self._authorizer: case AuthorizationCodeAuthorizer(): await self._login_with_auth_code(timeout_seconds) @@ -413,22 +473,28 @@ def _is_externally_authorized(self) -> bool: def _update_btn(self, signed_in: bool) -> None: if signed_in or self._is_externally_authorized: - 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()) + if isinstance(content, dict) and content.get("type") == "browser_token" and self._browser_token_event: + self._browser_token = content.get("token") if isinstance(content.get("token"), str) else None + self._browser_token_error = content.get("error") if isinstance(content.get("error"), str) else None + self._browser_token_event.set() + 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]: @@ -439,15 +505,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(): @@ -526,6 +583,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.""" @@ -533,11 +599,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) @@ -549,7 +611,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/test_service_manager_widget.py b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py index f608cc0f9..0c3f9ec82 100644 --- a/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -9,7 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import json +import sys import tempfile import unittest from unittest import mock @@ -84,22 +86,103 @@ def test_requires_a_token_outside_pyodide(self) -> None: with self.assertRaisesRegex(ValueError, "access token must be provided"): ServiceManagerWidget.with_access_token(cache_location=self.cache_location) - def test_reads_the_token_from_the_browser_in_pyodide(self) -> None: + async def test_receives_the_token_from_the_frontend_in_pyodide(self) -> None: + transport = TestTransport(base_url=DISCOVERY_URL) + + async def handler(*, url: str, **kwargs: object) -> MockResponse: + content = ( + json.dumps(load_test_data("successful_service_discovery.json")) + if "discovery" in url + else EMPTY_WORKSPACE_LIST + ) + return MockResponse(status_code=200, content=content, headers={"Content-Type": "application/json"}) + + transport.request.side_effect = handler with ( mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - mock.patch.dict( - "sys.modules", {"evo.pyodide": mock.MagicMock(get_browser_access_token=lambda: "browser-token")} - ), + mock.patch.object(ServiceManagerWidget, "_open_browser_token_channel", return_value="test"), ): - widget = self.build_widget() + widget = ServiceManagerWidget.with_access_token( + discovery_url=DISCOVERY_URL, cache_location=self.cache_location, transport=transport + ) + + widget._handle_frontend_msg(widget, {"type": "browser_token", "token": "browser-token"}, []) + await widget.login() + assert widget.organizations assert isinstance(widget._authorizer, AccessTokenAuthorizer) + assert await widget._authorizer.get_default_headers() == {"Authorization": "Bearer browser-token"} + + async def test_chained_login_receives_browser_token_during_the_same_cell(self) -> None: + transport = TestTransport(base_url=DISCOVERY_URL) + + async def handler(*, url: str, **kwargs: object) -> MockResponse: + content = ( + json.dumps(load_test_data("successful_service_discovery.json")) + if "discovery" in url + else EMPTY_WORKSPACE_LIST + ) + return MockResponse(status_code=200, content=content, headers={"Content-Type": "application/json"}) + + transport.request.side_effect = handler + channel = mock.Mock() + browser = mock.Mock() + browser.BroadcastChannel.new.return_value = channel + ffi = mock.Mock() + ffi.create_proxy.side_effect = lambda callback: mock.Mock(side_effect=callback) + + with ( + mock.patch.dict(sys.modules, {"js": browser, "pyodide": mock.Mock(), "pyodide.ffi": ffi}), + mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), + ): + widget = ServiceManagerWidget.with_access_token( + discovery_url=DISCOVERY_URL, cache_location=self.cache_location, transport=transport + ) + login_task = asyncio.create_task(widget.login()) + await asyncio.sleep(0) + listener = channel.addEventListener.call_args.args[1] + listener(mock.Mock(data=mock.Mock(to_py=lambda: {"type": "browser_token", "token": "browser-token"}))) + await login_task + + assert widget.organizations + assert await widget._authorizer.get_default_headers() == {"Authorization": "Bearer browser-token"} + channel.removeEventListener.assert_called_once() + channel.close.assert_called_once() + listener.destroy.assert_called_once() + + async def test_missing_browser_token_reports_sign_in(self) -> None: + with ( + mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), + mock.patch.object(ServiceManagerWidget, "_open_browser_token_channel", return_value="test"), + ): + widget = ServiceManagerWidget.with_access_token( + cache_location=self.cache_location, transport=TestTransport(base_url=DISCOVERY_URL) + ) + widget._handle_frontend_msg(widget, {"type": "browser_token", "error": "Sign in first."}, []) + with self.assertRaisesRegex(OAuthError, "Sign in first"): + await widget.login() + + async def test_browser_token_times_out_without_a_frontend(self) -> None: + with ( + mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), + mock.patch.object(ServiceManagerWidget, "_open_browser_token_channel", return_value="test"), + ): + widget = ServiceManagerWidget.with_access_token( + cache_location=self.cache_location, transport=TestTransport(base_url=DISCOVERY_URL) + ) + with self.assertRaisesRegex(OAuthError, "Timed out waiting"): + await widget.login(timeout_seconds=0) + + def test_explicit_token_never_requests_browser_storage(self) -> None: + widget = self.build_widget() + assert not widget.browser_token_required + assert widget._browser_token_event is None def test_button_invites_a_refresh_rather_than_a_sign_in(self) -> None: - assert self.build_widget()._btn.description == "Refresh Evo Services" + assert self.build_widget().button_text == "Refresh Evo Services" def test_button_invites_a_sign_in_for_the_auth_code_flow(self) -> None: widget = ServiceManagerWidget.with_auth_code(client_id="test", cache_location=self.cache_location) - assert widget._btn.description == "Sign In" + assert widget.button_text == "Sign In" def test_does_not_persist_the_token(self) -> None: """Unlike the auth code flow, an externally issued token must not be written to the cache.""" @@ -146,7 +229,7 @@ async def test_login_refreshes_the_service_list(self) -> None: def _select_first_real_org(self): # The first org in the test data uses the nil UUID, which is also the selector's "unselected" sentinel. org = next(org for org in self.widget.organizations if org.id != UUID(int=0)) - self.widget._org_selector.dropdown_widget.value = org.id + self.widget._org_selector.value = str(org.id) return org async def test_login_selects_an_organization_and_hub(self) -> None: diff --git a/uv.lock b/uv.lock index 57475110e..89b0ae76d 100644 --- a/uv.lock +++ b/uv.lock @@ -205,6 +205,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" @@ -1296,18 +1310,18 @@ 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" }, ] pyodide = [ + { 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 = "python-dotenv" }, ] @@ -1336,13 +1350,13 @@ test = [ [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'" }, + { name = "anywidget", marker = "extra == 'notebooks'" }, + { name = "anywidget", marker = "extra == 'pyodide'" }, { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, { name = "ipykernel", marker = "extra == 'notebooks'" }, { name = "ipykernel", marker = "extra == 'pyodide'" }, { name = "ipython", marker = "extra == 'notebooks'" }, { name = "ipython", marker = "extra == 'pyodide'" }, - { name = "ipywidgets", marker = "extra == 'notebooks'" }, - { name = "ipywidgets", marker = "extra == 'pyodide'" }, { name = "jmespath", marker = "extra == 'jmespath'", specifier = ">=1.0.1" }, { name = "pillow", marker = "extra == 'notebooks'" }, { name = "pure-interface", specifier = ">=8.0.0" }, @@ -3260,6 +3274,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" From 7315cc1de2e837274239284260df88771b45da9b Mon Sep 17 00:00:00 2001 From: eric daniels Date: Wed, 7 Oct 2026 10:59:36 -0600 Subject: [PATCH 4/8] correct browser transport forms, ranges, redirects, and timeouts --- .../src/evo/pyodide/transport.py | 82 ++++++++++---- .../evo-sdk-common/tests/pyodide/_stubs.py | 32 +++++- .../tests/pyodide/test_transport.py | 104 ++++++++++++++++-- 3 files changed, 181 insertions(+), 37 deletions(-) diff --git a/packages/evo-sdk-common/src/evo/pyodide/transport.py b/packages/evo-sdk-common/src/evo/pyodide/transport.py index 94aa1cea1..3544190b2 100644 --- a/packages/evo-sdk-common/src/evo/pyodide/transport.py +++ b/packages/evo-sdk-common/src/evo/pyodide/transport.py @@ -12,16 +12,19 @@ from __future__ import annotations try: - from js import FormData + from js import AbortController, Blob, FormData + from pyodide.ffi import to_js from pyodide.http import pyfetch except ImportError: raise ImportError("JsTransport cannot be used because it is not running in a Pyodide runtime.") -import contextlib +import asyncio import json +import re +from time import monotonic from types import TracebackType -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit from evo.common import HTTPHeaderDict, HTTPResponse, RequestMethod from evo.common.exceptions import ClientValueError, TransportError @@ -78,9 +81,10 @@ def _build_body( if post_params and any(form_type in content_type for form_type in _FORM_CONTENT_TYPES): if content_type == "application/x-www-form-urlencoded": return urlencode(post_params) + headers.pop("Content-Type") form_data = FormData.new() for key, value in post_params: - form_data.append(key, value) + form_data.append(key, Blob.new(to_js([value])) if isinstance(value, bytes) else value) return form_data if body is not None and not isinstance(body, (str, bytes)): @@ -101,48 +105,80 @@ async def request( if post_params is not None and body is not None: raise ClientValueError(msg="HTTP body and post parameters cannot be used at the same time.") + controller = AbortController.new() + deadline = monotonic() + request_timeout if isinstance(request_timeout, (int, float)) else None + connect_timeout, read_timeout = request_timeout if isinstance(request_timeout, tuple) else (None, None) + + def timeout_for(stage: str) -> float | None: + if deadline is not None: + return max(0, deadline - monotonic()) + return connect_timeout if stage == "connect" else read_timeout + + async def fetch(fetch_url: str, **options: object) -> object: + return await asyncio.wait_for( + pyfetch(fetch_url, signal=controller.signal, **options), timeout=timeout_for("connect") + ) + try: request_headers = dict(headers or {}) request_headers.setdefault("User-Agent", self.user_agent) request_body = self._build_body(request_headers, post_params or [], body) - # Browsers often strip headers from HEAD responses due to CORS, but the SDK needs Content-Length and - # Accept-Ranges, so issue a GET and discard the body instead. is_head = method == RequestMethod.HEAD - actual_method = "GET" if is_head else str(method) - - resp = await pyfetch( + resp = await fetch( url, - method=actual_method, + method=str(method), headers=request_headers, body=request_body, redirect="follow", ) resp_headers = HTTPHeaderDict(resp.headers) - # Fetch hides the original 303 when following a compute task submission redirect. Recreate it from the - # final status URL so the SDK can construct and poll the submitted job. - if method == RequestMethod.POST and resp.redirected and resp.status == 202: - resp_headers["Location"] = str(resp.url) - return HTTPResponse(status=303, data=b"", reason="See Other", headers=resp_headers) + if ( + is_head + and resp.status == 200 + and ("Content-Length" not in resp_headers or "Accept-Ranges" not in resp_headers) + ): + probe_headers = {**request_headers, "Range": "bytes=0-0"} + probe = await fetch(url, method="GET", headers=probe_headers, redirect="follow") + content_range = HTTPHeaderDict(probe.headers).get("Content-Range", "") + match = re.fullmatch(r"bytes 0-0/(\d+)", content_range) + if probe.status == 206 and match: + resp_headers["Content-Length"] = match.group(1) + resp_headers["Accept-Ranges"] = "bytes" + + if resp.redirected: + request_path = urlsplit(url).path.rstrip("/") + status_path = urlsplit(str(resp.url)).path + if ( + method == RequestMethod.POST + and resp.status == 202 + and re.fullmatch(r".*/compute/orgs/[^/]+/[^/]+/[^/]+", request_path) + and re.fullmatch(re.escape(request_path) + r"/[^/]+/status", status_path) + ): + resp_headers["Location"] = str(resp.url) + return HTTPResponse(status=303, data=b"", reason="See Other", headers=resp_headers) + raise TransportError(msg="Browser fetch followed a redirect whose original response is unavailable.") # Redirect responses carry their information in the Location header and have no body to read. if 300 <= resp.status < 400: return HTTPResponse(status=resp.status, data=b"", reason=resp.status_text, headers=resp_headers) - data = await resp.bytes() - if is_head: - resp_headers.setdefault("Accept-Ranges", "bytes") - resp_headers.setdefault("Content-Length", str(len(data))) return HTTPResponse(status=resp.status, data=b"", reason=resp.status_text, headers=resp_headers) + data = await asyncio.wait_for(resp.bytes(), timeout=timeout_for("read")) range_header = request_headers.get("Range") or request_headers.get("range") - if range_header and "Content-Range" not in resp_headers: - with contextlib.suppress(IndexError, ValueError): - start, end = range_header.split("=")[1].split("-") - resp_headers["Content-Range"] = f"bytes {start}-{end}/*" + if range_header and resp.status == 206 and "Content-Range" not in resp_headers: + if match := re.fullmatch(r"bytes=(\d+)-(\d+)", range_header): + start, end = map(int, match.groups()) + if end >= start and len(data) == end - start + 1: + resp_headers["Content-Range"] = f"bytes {start}-{end}/*" return HTTPResponse(status=resp.status, data=data, reason=resp.status_text, headers=resp_headers) + except TransportError: + raise except Exception as e: raise TransportError(msg="Could not complete HTTP request", caused_by=e) + finally: + controller.abort() diff --git a/packages/evo-sdk-common/tests/pyodide/_stubs.py b/packages/evo-sdk-common/tests/pyodide/_stubs.py index 675ee3554..bee806747 100644 --- a/packages/evo-sdk-common/tests/pyodide/_stubs.py +++ b/packages/evo-sdk-common/tests/pyodide/_stubs.py @@ -29,16 +29,38 @@ class FakeFormData: """Stand-in for the browser `FormData` object.""" def __init__(self) -> None: - self.fields: list[tuple[str, str | bytes]] = [] + self.fields: list[tuple[str, str | FakeBlob]] = [] @classmethod def new(cls) -> FakeFormData: return cls() - def append(self, key: str, value: str | bytes) -> None: + def append(self, key: str, value: str | FakeBlob) -> None: self.fields.append((key, value)) +class FakeBlob: + def __init__(self, parts: list[bytes]) -> None: + self.parts = parts + + @classmethod + def new(cls, parts: list[bytes]) -> FakeBlob: + return cls(parts) + + +class FakeAbortController: + def __init__(self) -> None: + self.signal = self + self.aborted = False + + @classmethod + def new(cls) -> FakeAbortController: + return cls() + + def abort(self) -> None: + self.aborted = True + + class FakeLocalStorage: """Stand-in for the browser `localStorage` object.""" @@ -129,6 +151,8 @@ def install() -> None: """Register the stand-in runtime modules so that `evo.pyodide` can be imported.""" if "js" not in sys.modules: js = _new_module("js") + js.AbortController = FakeAbortController + js.Blob = FakeBlob js.FormData = FakeFormData js.localStorage = FakeLocalStorage() sys.modules["js"] = js @@ -136,12 +160,16 @@ def install() -> None: if "pyodide" not in sys.modules: pyodide = _new_module("pyodide") pyodide.__path__ = [] + ffi = _new_module("pyodide.ffi") + ffi.to_js = lambda value: value http = _new_module("pyodide.http") async def pyfetch(*args: Any, **kwargs: Any) -> FakeResponse: raise AssertionError("pyfetch must be patched by the test.") http.pyfetch = pyfetch + pyodide.ffi = ffi pyodide.http = http sys.modules["pyodide"] = pyodide + sys.modules["pyodide.ffi"] = ffi sys.modules["pyodide.http"] = http diff --git a/packages/evo-sdk-common/tests/pyodide/test_transport.py b/packages/evo-sdk-common/tests/pyodide/test_transport.py index f1324d7af..db953676e 100644 --- a/packages/evo-sdk-common/tests/pyodide/test_transport.py +++ b/packages/evo-sdk-common/tests/pyodide/test_transport.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import json import unittest from unittest import mock @@ -17,7 +18,7 @@ from evo.common.exceptions import ClientValueError, TransportError from evo.common.interfaces import ITransport -from ._stubs import FakeFetch, FakeFormData, FakeResponse, install +from ._stubs import FakeBlob, FakeFetch, FakeFormData, FakeResponse, install install() @@ -129,23 +130,46 @@ async def test_builds_form_data_for_multipart_post_params(self) -> None: body = fetch.last_call["body"] assert isinstance(body, FakeFormData) - assert body.fields == [("file", b"bytes"), ("name", "value")] - - async def test_head_request_is_sent_as_get_without_body(self) -> None: - response = FakeResponse(status=200, body=b"0123456789") + assert body.fields[0][0] == "file" + assert isinstance(body.fields[0][1], FakeBlob) + assert body.fields[0][1].parts == [b"bytes"] + assert body.fields[1] == ("name", "value") + assert "Content-Type" not in fetch.last_call["headers"] + + async def test_head_request_uses_server_headers_without_reading_body(self) -> None: + response = FakeResponse(status=200, headers={"Content-Length": "10", "Accept-Ranges": "bytes"}) fetch = self.patch_fetch(response) result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") - assert fetch.last_call["method"] == "GET" + assert fetch.last_call["method"] == "HEAD" assert result.data == b"" assert result.headers["Content-Length"] == "10" assert result.headers["Accept-Ranges"] == "bytes" + assert not response.body_read + + async def test_head_probes_a_byte_if_cors_hides_required_headers(self) -> None: + fetch = self.patch_fetch(FakeResponse(), FakeResponse(status=206, headers={"Content-Range": "bytes 0-0/987"})) + + result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") + + assert [call[1]["method"] for call in fetch.calls] == ["HEAD", "GET"] + assert fetch.last_call["headers"]["Range"] == "bytes=0-0" + assert result.headers["Content-Length"] == "987" + assert result.headers["Accept-Ranges"] == "bytes" + + async def test_head_does_not_claim_range_support_without_evidence(self) -> None: + probe = FakeResponse(status=200, body=b"large file") + self.patch_fetch(FakeResponse(), probe) + + result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") + + assert "Accept-Ranges" not in result.headers + assert "Content-Length" not in result.headers + assert not probe.body_read async def test_head_request_keeps_headers_reported_by_the_server(self) -> None: - response = FakeResponse( - status=200, body=b"0123456789", headers={"Content-Length": "99", "Accept-Ranges": "none"} - ) + response = FakeResponse(status=200, headers={"Content-Length": "99", "Accept-Ranges": "none"}) self.patch_fetch(response) result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") @@ -165,14 +189,23 @@ async def test_redirect_response_has_no_body(self) -> None: assert not response.body_read async def test_followed_task_submission_redirect_is_restored_as_303(self) -> None: - response = FakeResponse(status=202, url="https://example.test/tasks/1", redirected=True) + response = FakeResponse( + status=202, url="https://example.test/compute/orgs/org/topic/task/job/status", redirected=True + ) self.patch_fetch(response) - result = await self.transport.request(RequestMethod.POST, "https://example.test/tasks") + result = await self.transport.request(RequestMethod.POST, "https://example.test/compute/orgs/org/topic/task") assert result.status == 303 assert result.reason == "See Other" - assert result.headers["Location"] == "https://example.test/tasks/1" + assert result.headers["Location"] == "https://example.test/compute/orgs/org/topic/task/job/status" + + async def test_unrelated_followed_redirect_fails_instead_of_returning_final_response(self) -> None: + response = FakeResponse(status=200, url="https://example.test/file", redirected=True) + self.patch_fetch(response) + + with self.assertRaisesRegex(TransportError, "original response is unavailable"): + await self.transport.request(RequestMethod.PUT, "https://example.test/files/1") async def test_unredirected_202_is_left_alone(self) -> None: self.patch_fetch(FakeResponse(status=202, body=b"accepted")) @@ -200,6 +233,24 @@ async def test_keeps_content_range_reported_by_the_server(self) -> None: assert result.headers["Content-Range"] == "bytes 0-3/10" + async def test_does_not_invent_content_range_if_server_ignores_range(self) -> None: + self.patch_fetch(FakeResponse(status=200, body=b"full file")) + + result = await self.transport.request( + RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) + ) + + assert "Content-Range" not in result.headers + + async def test_does_not_invent_content_range_if_length_mismatches(self) -> None: + self.patch_fetch(FakeResponse(status=206, body=b"012345")) + + result = await self.transport.request( + RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) + ) + + assert "Content-Range" not in result.headers + async def test_ignores_malformed_range_header(self) -> None: self.patch_fetch(FakeResponse(status=206, body=b"0123")) @@ -216,3 +267,32 @@ async def test_wraps_fetch_failures_in_transport_error(self) -> None: await self.transport.request(RequestMethod.GET, "https://example.test/") assert isinstance(ctx.exception.caused_by, RuntimeError) + + async def test_connection_timeout_aborts_fetch(self) -> None: + signals = [] + + async def stalled_fetch(url: str, **options: object) -> FakeResponse: + signals.append(options["signal"]) + await asyncio.Event().wait() + raise AssertionError("unreachable") + + with mock.patch.object(transport_module, "pyfetch", stalled_fetch): + with self.assertRaises(TransportError) as ctx: + await self.transport.request(RequestMethod.GET, "https://example.test/", request_timeout=(0.01, 1)) + + assert isinstance(ctx.exception.caused_by, TimeoutError) + assert signals[0].aborted + + async def test_total_timeout_aborts_body_read(self) -> None: + class StalledResponse(FakeResponse): + async def bytes(self) -> bytes: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + fetch = self.patch_fetch(StalledResponse()) + + with self.assertRaises(TransportError) as ctx: + await self.transport.request(RequestMethod.GET, "https://example.test/", request_timeout=0.01) + + assert isinstance(ctx.exception.caused_by, TimeoutError) + assert fetch.last_call["signal"].aborted From 66b5d4d7da2dc32ea965cfb52a8db3f91f56919b Mon Sep 17 00:00:00 2001 From: Eric Daniels <106169966+geostats-BSY@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:51:40 -0600 Subject: [PATCH 5/8] fix login retry --- .../evo/notebooks/assets/service_manager.js | 20 +++-- .../src/evo/notebooks/widgets.py | 26 ++++--- .../test_service_manager_frontend.mjs | 73 +++++++++++++++++++ .../notebooks/test_service_manager_widget.py | 61 ++++++++++++++++ 4 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs 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 index e2a146fb1..6af4ceb35 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js @@ -54,9 +54,7 @@ function render({ model, el }) { renderLoading(); renderMessage(); - const channelId = model.get("browser_token_channel"); - const tokenChannel = channelId && typeof BroadcastChannel !== "undefined" - ? new BroadcastChannel(`evo-browser-token-${channelId}`) : null; + let tokenChannel = null; const sendBrowserToken = (message) => { if (tokenChannel) { tokenChannel.postMessage(message); @@ -65,7 +63,15 @@ function render({ model, el }) { } }; - if (model.get("browser_token_required")) { + const requestBrowserToken = () => { + tokenChannel?.close(); + tokenChannel = null; + const channelId = model.get("browser_token_channel"); + if (!model.get("browser_token_required") || !channelId) { + return; + } + tokenChannel = typeof BroadcastChannel !== "undefined" + ? new BroadcastChannel(`evo-browser-token-${channelId}`) : null; try { const stored = localStorage.getItem("accessToken"); if (!stored) { @@ -81,7 +87,10 @@ function render({ model, el }) { ? "The browser access token document is invalid." : error instanceof Error ? error.message : "Cannot read the browser access token." }); } - } + }; + + model.on("change:browser_token_channel", requestBrowserToken); + requestBrowserToken(); return () => { button.removeEventListener("click", onClick); @@ -89,6 +98,7 @@ function render({ model, el }) { model.off("change:disabled", renderDisabled); model.off("change:loading", renderLoading); model.off("change:message", renderMessage); + model.off("change:browser_token_channel", requestBrowserToken); tokenChannel?.close(); }; } diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py index 53438b815..c2cc8dc3f 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -364,7 +364,8 @@ def with_access_token( ``` :param access_token: The access token to authorise requests with. When omitted in Pyodide, the widget frontend - reads it from the hosting page's local storage during login. + reads it from the hosting page's local storage during login. If this fails, sign in on the hosting page + and call `login()` again on the same widget to retry. :param discovery_url: The URL of the Evo Discovery service. :param cache_location: The location of the cache file. :param transport: The transport to use for API requests. Defaults to the browser `fetch` transport in a Pyodide @@ -401,23 +402,28 @@ async def _receive_browser_token(self, timeout_seconds: int) -> None: event = self._browser_token_event if event is None: return + if not self.browser_token_channel and sys.platform == "emscripten": + self.browser_token_channel = self._open_browser_token_channel() try: await asyncio.wait_for(event.wait(), timeout_seconds) + if self._browser_token_error: + raise OAuthError(self._browser_token_error) + if not self._browser_token: + raise OAuthError("No access token received from the hosting page. Sign in first.") + + self._authorizer = AccessTokenAuthorizer(self._browser_token) + self._service_manager._authorizer = self._authorizer + self._browser_token_event = None except TimeoutError as exc: raise OAuthError( "Timed out waiting for the browser access token. Display the widget and try again." ) from exc finally: self._close_browser_token_channel() - if self._browser_token_error: - raise OAuthError(self._browser_token_error) - if not self._browser_token: - raise OAuthError("No access token received from the hosting page. Sign in first.") - - self._authorizer = AccessTokenAuthorizer(self._browser_token) - self._service_manager._authorizer = self._authorizer - self._browser_token = None - self._browser_token_event = None + self.browser_token_channel = "" + event.clear() + self._browser_token = None + self._browser_token_error = None async def _login_with_auth_code(self, timeout_seconds: int) -> None: """Login using an authorization code authorizer. 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..8ea37c59c --- /dev/null +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs @@ -0,0 +1,73 @@ +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("browser token retries reread storage and clean up channels and listeners", () => { + for (const useBroadcastChannel of [true, false]) { + const channels = []; + const messages = []; + const handlers = new Map(); + const values = { + browser_token_required: true, + browser_token_channel: "first", + }; + let storedToken = null; + globalThis.document = { + createElement: () => ({ + style: {}, + append() {}, + addEventListener() {}, + removeEventListener() {}, + }), + }; + globalThis.localStorage = { getItem: () => storedToken }; + globalThis.BroadcastChannel = useBroadcastChannel ? class { + constructor(name) { + this.name = name; + this.closed = false; + channels.push(this); + } + postMessage(message) { + assert.equal(this.closed, false); + messages.push(message); + } + close() { + this.closed = true; + } + } : undefined; + 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); + }, + }; + const cleanup = render({ model, el: { appendChild() {} } }); + try { + assert.match(messages[0].error, /Sign in first/); + values.browser_token_channel = ""; + handlers.get("change:browser_token_channel")(); + assert.equal(messages.length, 1); + storedToken = JSON.stringify({ access_token: "retry-token" }); + values.browser_token_channel = "retry"; + handlers.get("change:browser_token_channel")(); + assert.deepEqual(messages[1], { type: "browser_token", token: "retry-token" }); + if (useBroadcastChannel) { + assert.equal(channels[0].closed, true); + assert.equal(channels[1].name, "evo-browser-token-retry"); + } + } finally { + cleanup(); + delete globalThis.document; + delete globalThis.localStorage; + delete globalThis.BroadcastChannel; + } + assert.equal(handlers.size, 0); + assert.ok(channels.every((channel) => channel.closed)); + } +}); 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 index 0c3f9ec82..e5728dee8 100644 --- a/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -172,6 +172,67 @@ async def test_browser_token_times_out_without_a_frontend(self) -> None: with self.assertRaisesRegex(OAuthError, "Timed out waiting"): await widget.login(timeout_seconds=0) + async def test_browser_token_login_can_retry_after_failure(self) -> None: + for failure in ("timeout", "error", "empty", "cancel"): + with self.subTest(failure=failure): + browser = mock.Mock() + channels = [mock.Mock(), mock.Mock()] + browser.BroadcastChannel.new.side_effect = channels + ffi = mock.Mock() + ffi.create_proxy.side_effect = lambda callback: mock.Mock(side_effect=callback) + + with ( + mock.patch.dict(sys.modules, {"js": browser, "pyodide": mock.Mock(), "pyodide.ffi": ffi}), + mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), + ): + widget = ServiceManagerWidget.with_access_token( + cache_location=self.cache_location, transport=TestTransport(base_url=DISCOVERY_URL) + ) + original_channel_id = widget.browser_token_channel + first_listener = channels[0].addEventListener.call_args.args[1] + if failure in ("error", "empty"): + content = {"type": "browser_token"} + if failure == "error": + content["error"] = "Sign in first." + first_listener(mock.Mock(data=mock.Mock(to_py=lambda: content))) + if failure == "cancel": + task = asyncio.create_task(widget.login()) + await asyncio.sleep(0) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + else: + with self.assertRaises(OAuthError): + await widget.login(timeout_seconds=0 if failure == "timeout" else 1) + + channels[0].close.assert_called_once() + first_listener.destroy.assert_called_once() + with mock.patch.object(widget, "refresh_services", new_callable=mock.AsyncMock): + retry = asyncio.create_task(widget.login(timeout_seconds=1)) + try: + await asyncio.sleep(0) + assert widget.browser_token_channel != original_channel_id + assert browser.BroadcastChannel.new.call_count == 2 + listener = channels[1].addEventListener.call_args.args[1] + listener( + mock.Mock( + data=mock.Mock(to_py=lambda: {"type": "browser_token", "token": "retry-token"}) + ) + ) + assert await retry is widget + finally: + if not retry.done(): + retry.cancel() + with self.assertRaises(asyncio.CancelledError): + await retry + + assert widget._browser_token_event is None + assert widget._browser_token_error is None + channels[1].close.assert_called_once() + listener.destroy.assert_called_once() + headers = await widget._authorizer.get_default_headers() + assert headers["Authorization"] == "Bearer retry-token" + def test_explicit_token_never_requests_browser_storage(self) -> None: widget = self.build_widget() assert not widget.browser_token_required From 0f29ed426811d9548b77ca17e9124089c170c8ac Mon Sep 17 00:00:00 2001 From: Eric Daniels <106169966+geostats-BSY@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:39:42 -0500 Subject: [PATCH 6/8] fix failing python 3.10 tests --- .../src/evo/notebooks/widgets.py | 32 +++- .../src/evo/pyodide/transport.py | 8 +- .../notebooks/test_service_manager_widget.py | 175 ++++++++++++++++++ .../tests/pyodide/test_transport.py | 57 +++++- 4 files changed, 256 insertions(+), 16 deletions(-) diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py index c2cc8dc3f..bcf33086b 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -242,6 +242,7 @@ def __init__( :param discovery_url: The URL of the Evo Discovery service. :param cache: The cache to use for storing tokens and other data. """ + self._widget_closed = False self._authorizer = authorizer self._cache = cache self._browser_token_event = asyncio.Event() if browser_token else None @@ -287,13 +288,29 @@ def _open_browser_token_channel(self) -> str: return channel_id def _close_browser_token_channel(self) -> None: - if self._browser_channel is not None: + if getattr(self, "_browser_channel", None) is not None: self._browser_channel.removeEventListener("message", self._browser_channel_listener) self._browser_channel.close() self._browser_channel_listener.destroy() self._browser_channel = None self._browser_channel_listener = None + def close(self) -> None: + """Release browser-token resources and close the widget, ending any pending token wait.""" + self._widget_closed = True + self._close_browser_token_channel() + self.browser_token_channel = "" + event = getattr(self, "_browser_token_event", None) + if event is not None: + event.set() + self._browser_token = None + self._browser_token_error = None + super().close() + + def _check_not_closed(self) -> None: + if self._widget_closed: + raise OAuthError("This ServiceManagerWidget is closed. Create a new widget to continue.") + @classmethod def with_auth_code( cls, @@ -399,6 +416,7 @@ def with_access_token( ) async def _receive_browser_token(self, timeout_seconds: int) -> None: + self._check_not_closed() event = self._browser_token_event if event is None: return @@ -406,6 +424,7 @@ async def _receive_browser_token(self, timeout_seconds: int) -> None: self.browser_token_channel = self._open_browser_token_channel() try: await asyncio.wait_for(event.wait(), timeout_seconds) + self._check_not_closed() if self._browser_token_error: raise OAuthError(self._browser_token_error) if not self._browser_token: @@ -414,7 +433,7 @@ async def _receive_browser_token(self, timeout_seconds: int) -> None: self._authorizer = AccessTokenAuthorizer(self._browser_token) self._service_manager._authorizer = self._authorizer self._browser_token_event = None - except TimeoutError as exc: + except asyncio.TimeoutError as exc: raise OAuthError( "Timed out waiting for the browser access token. Display the widget and try again." ) from exc @@ -451,7 +470,9 @@ async def login(self, timeout_seconds: int = 180) -> ServiceManagerWidget: :param timeout_seconds: The maximum time (in seconds) to wait for the authorisation process to complete. :returns: The current instance of the ServiceManagerWidget. + :raises OAuthError: If the widget is closed. """ + self._check_not_closed() # Open the transport without closing it to avoid the overhead of opening it multiple times. await self._service_manager._transport.open() with self._loading(): @@ -484,8 +505,12 @@ def _update_btn(self, signed_in: bool) -> None: self.button_text = "Sign In" def _handle_frontend_msg(self, _widget: object, content: object, _buffers: list) -> asyncio.Future | None: + if self._widget_closed: + return None if isinstance(content, dict) and content.get("type") == "click": - return asyncio.ensure_future(self.refresh_services()) + return asyncio.ensure_future( + self.login() if self._browser_token_event is not None else self.refresh_services() + ) if isinstance(content, dict) and content.get("type") == "browser_token" and self._browser_token_event: self._browser_token = content.get("token") if isinstance(content.get("token"), str) else None self._browser_token_error = content.get("error") if isinstance(content.get("error"), str) else None @@ -512,6 +537,7 @@ def _loading_services(self) -> Iterator[None]: self._org_selector.refresh() async def refresh_services(self) -> None: + self._check_not_closed() with self._loading(): with self._loading_services(): try: diff --git a/packages/evo-sdk-common/src/evo/pyodide/transport.py b/packages/evo-sdk-common/src/evo/pyodide/transport.py index 3544190b2..de10a1fce 100644 --- a/packages/evo-sdk-common/src/evo/pyodide/transport.py +++ b/packages/evo-sdk-common/src/evo/pyodide/transport.py @@ -144,15 +144,17 @@ async def fetch(fetch_url: str, **options: object) -> object: content_range = HTTPHeaderDict(probe.headers).get("Content-Range", "") match = re.fullmatch(r"bytes 0-0/(\d+)", content_range) if probe.status == 206 and match: - resp_headers["Content-Length"] = match.group(1) - resp_headers["Accept-Ranges"] = "bytes" + if "Content-Length" not in resp_headers: + resp_headers["Content-Length"] = match.group(1) + if "Accept-Ranges" not in resp_headers: + resp_headers["Accept-Ranges"] = "bytes" if resp.redirected: request_path = urlsplit(url).path.rstrip("/") status_path = urlsplit(str(resp.url)).path if ( method == RequestMethod.POST - and resp.status == 202 + and resp.status in (200, 202) and re.fullmatch(r".*/compute/orgs/[^/]+/[^/]+/[^/]+", request_path) and re.fullmatch(re.escape(request_path) + r"/[^/]+/status", status_path) ): 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 index e5728dee8..abac7c98b 100644 --- a/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -287,6 +287,16 @@ async def test_login_refreshes_the_service_list(self) -> None: await self.widget.login() assert self.widget.organizations + async def test_explicit_token_click_refreshes_without_logging_in(self) -> None: + self._respond() + with mock.patch.object(self.widget, "login", new_callable=mock.AsyncMock) as login: + task = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) + assert task is not None + await task + + assert self.widget.organizations + login.assert_not_awaited() + def _select_first_real_org(self): # The first org in the test data uses the nil UUID, which is also the selector's "unselected" sentinel. org = next(org for org in self.widget.organizations if org.id != UUID(int=0)) @@ -329,3 +339,168 @@ async def test_unsupported_authorizer_is_rejected(self) -> None: self.widget._authorizer = mock.Mock() with self.assertRaises(NotImplementedError): await self.widget.login() + + +class TestBrowserTokenLifecycle(_ServiceManagerWidgetTestCase): + def setUp(self) -> None: + super().setUp() + self.channels = [mock.Mock(), mock.Mock()] + browser = mock.Mock() + browser.BroadcastChannel.new.side_effect = self.channels + ffi = mock.Mock() + ffi.create_proxy.side_effect = lambda callback: mock.Mock(side_effect=callback) + for patcher in ( + mock.patch.dict(sys.modules, {"js": browser, "pyodide": mock.Mock(), "pyodide.ffi": ffi}), + mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), + ): + patcher.start() + self.addCleanup(patcher.stop) + + self.transport = TestTransport(base_url=DISCOVERY_URL) + + async def handler(*, url: str, **kwargs: object) -> MockResponse: + content = ( + json.dumps(load_test_data("successful_service_discovery.json")) + if "discovery" in url + else EMPTY_WORKSPACE_LIST + ) + return MockResponse(status_code=200, content=content, headers={"Content-Type": "application/json"}) + + self.transport.request.side_effect = handler + self.widget = ServiceManagerWidget.with_access_token( + discovery_url=DISCOVERY_URL, cache_location=self.cache_location, transport=self.transport + ) + self.addCleanup(self.widget.close) + + def send_browser_message(self, content: dict[str, str], channel_index: int = 0) -> None: + listener = self.channels[channel_index].addEventListener.call_args.args[1] + listener(mock.Mock(data=mock.Mock(to_py=lambda: content))) + + def assert_channel_closed(self, channel_index: int = 0) -> None: + channel = self.channels[channel_index] + listener = channel.addEventListener.call_args.args[1] + channel.removeEventListener.assert_called_once_with("message", listener) + channel.close.assert_called_once() + listener.destroy.assert_called_once() + + async def test_click_installs_already_received_token_before_discovery(self) -> None: + self.send_browser_message({"type": "browser_token", "token": "browser-token"}) + + task = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) + assert task is not None + await task + + assert self.widget.organizations + expected = await AccessTokenAuthorizer("browser-token").get_default_headers() + for call in self.transport.request.await_args_list: + assert call.kwargs["headers"]["Authorization"] == expected["Authorization"] + discovery_calls = [call for call in self.transport.request.await_args_list if "discovery" in call.kwargs["url"]] + assert len(discovery_calls) == 1 + self.assert_channel_closed() + + async def test_click_waits_for_browser_token_before_discovery(self) -> None: + task = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) + assert task is not None + try: + await asyncio.sleep(0) + self.transport.request.assert_not_awaited() + self.send_browser_message({"type": "browser_token", "token": "browser-token"}) + await asyncio.wait_for(task, 1) + finally: + if not task.done(): + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + assert self.widget.organizations + assert not self.widget.loading + assert not self.widget.disabled + + async def test_click_retries_browser_token_failure(self) -> None: + self.send_browser_message({"type": "browser_token", "error": "Sign in first."}) + first = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) + assert first is not None + with self.assertRaisesRegex(OAuthError, "Sign in first"): + await first + self.transport.request.assert_not_awaited() + assert not self.widget.loading + assert not self.widget.disabled + self.assert_channel_closed() + + retry = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) + assert retry is not None + try: + await asyncio.sleep(0) + self.send_browser_message({"type": "browser_token", "token": "retry-token"}, channel_index=1) + await asyncio.wait_for(retry, 1) + finally: + if not retry.done(): + retry.cancel() + with self.assertRaises(asyncio.CancelledError): + await retry + assert self.widget.organizations + self.assert_channel_closed(channel_index=1) + + async def test_close_releases_channel_and_rejects_reuse(self) -> None: + self.widget.close() + self.widget.close() + + self.assert_channel_closed() + assert self.widget.comm is None + assert self.widget.browser_token_channel == "" + for operation in (self.widget.login, self.widget.refresh_services): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(OAuthError, "closed"): + await operation() + self.transport.open.assert_not_awaited() + self.transport.request.assert_not_awaited() + self.transport.close.assert_not_awaited() + + for content in ({"type": "click"}, {"type": "browser_token", "token": "late-token"}): + assert self.widget._handle_frontend_msg(self.widget, content, []) is None + assert self.widget._browser_token is None + self.channels[1].addEventListener.assert_not_called() + + async def test_close_promptly_ends_pending_login(self) -> None: + task = asyncio.create_task(self.widget.login()) + try: + await asyncio.sleep(0) + assert not task.done() + self.widget.close() + + with self.assertRaisesRegex(OAuthError, "closed"): + await asyncio.wait_for(task, 1) + finally: + if not task.done(): + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + self.assert_channel_closed() + self.transport.request.assert_not_awaited() + assert self.widget._browser_token is None + assert self.widget._browser_token_error is None + assert not self.widget.loading + + async def test_close_after_login_does_not_repeat_channel_cleanup(self) -> None: + self.send_browser_message({"type": "browser_token", "token": "browser-token"}) + await self.widget.login() + + self.widget.close() + self.widget.close() + + self.assert_channel_closed() + + def test_close_after_partial_initialization_releases_channel(self) -> None: + widget = ServiceManagerWidget.__new__(ServiceManagerWidget) + self.addCleanup(widget.close) + with mock.patch("evo.notebooks.widgets.ServiceManager", side_effect=ValueError("Initialization failed")): + with self.assertRaisesRegex(ValueError, "Initialization failed"): + widget.__init__( + self.transport, AccessTokenAuthorizer(""), DISCOVERY_URL, self.widget.cache, browser_token=True + ) + + widget.close() + widget.close() + + self.assert_channel_closed(channel_index=1) diff --git a/packages/evo-sdk-common/tests/pyodide/test_transport.py b/packages/evo-sdk-common/tests/pyodide/test_transport.py index db953676e..d960a0a3f 100644 --- a/packages/evo-sdk-common/tests/pyodide/test_transport.py +++ b/packages/evo-sdk-common/tests/pyodide/test_transport.py @@ -17,6 +17,7 @@ from evo.common import HTTPHeaderDict, RequestMethod from evo.common.exceptions import ClientValueError, TransportError from evo.common.interfaces import ITransport +from evo.common.io.http import HTTPSource from ._stubs import FakeBlob, FakeFetch, FakeFormData, FakeResponse, install @@ -158,6 +159,28 @@ async def test_head_probes_a_byte_if_cors_hides_required_headers(self) -> None: assert result.headers["Content-Length"] == "987" assert result.headers["Accept-Ranges"] == "bytes" + async def test_head_probe_preserves_partially_exposed_headers(self) -> None: + for headers in ({"Content-Length": "987"}, {"Accept-Ranges": "bytes"}): + with self.subTest(headers=headers): + self.patch_fetch( + FakeResponse(headers=headers), + FakeResponse(status=206, headers={"Content-Range": "bytes 0-0/987"}), + ) + + result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") + + assert result.headers["Content-Length"] == "987" + assert result.headers["Accept-Ranges"] == "bytes" + + async def test_http_source_accepts_size_when_head_hides_range_support(self) -> None: + self.patch_fetch( + FakeResponse(headers={"Content-Length": "987"}), + FakeResponse(status=206, headers={"Content-Range": "bytes 0-0/987"}), + ) + + async with HTTPSource(mock.AsyncMock(return_value="https://example.test/file"), self.transport) as source: + assert await source.get_size() == 987 + async def test_head_does_not_claim_range_support_without_evidence(self) -> None: probe = FakeResponse(status=200, body=b"large file") self.patch_fetch(FakeResponse(), probe) @@ -189,16 +212,30 @@ async def test_redirect_response_has_no_body(self) -> None: assert not response.body_read async def test_followed_task_submission_redirect_is_restored_as_303(self) -> None: - response = FakeResponse( - status=202, url="https://example.test/compute/orgs/org/topic/task/job/status", redirected=True + for status in (200, 202): + with self.subTest(status=status): + response = FakeResponse( + status=status, url="https://example.test/compute/orgs/org/topic/task/job/status", redirected=True + ) + self.patch_fetch(response) + + result = await self.transport.request( + RequestMethod.POST, "https://example.test/compute/orgs/org/topic/task" + ) + + assert result.status == 303 + assert result.reason == "See Other" + assert result.headers["Location"] == "https://example.test/compute/orgs/org/topic/task/job/status" + assert result.data == b"" + assert not response.body_read + + async def test_unsuccessful_task_submission_redirect_is_not_restored(self) -> None: + self.patch_fetch( + FakeResponse(status=500, url="https://example.test/compute/orgs/org/topic/task/job/status", redirected=True) ) - self.patch_fetch(response) - result = await self.transport.request(RequestMethod.POST, "https://example.test/compute/orgs/org/topic/task") - - assert result.status == 303 - assert result.reason == "See Other" - assert result.headers["Location"] == "https://example.test/compute/orgs/org/topic/task/job/status" + with self.assertRaisesRegex(TransportError, "original response is unavailable"): + await self.transport.request(RequestMethod.POST, "https://example.test/compute/orgs/org/topic/task") async def test_unrelated_followed_redirect_fails_instead_of_returning_final_response(self) -> None: response = FakeResponse(status=200, url="https://example.test/file", redirected=True) @@ -280,7 +317,7 @@ async def stalled_fetch(url: str, **options: object) -> FakeResponse: with self.assertRaises(TransportError) as ctx: await self.transport.request(RequestMethod.GET, "https://example.test/", request_timeout=(0.01, 1)) - assert isinstance(ctx.exception.caused_by, TimeoutError) + assert isinstance(ctx.exception.caused_by, asyncio.TimeoutError) assert signals[0].aborted async def test_total_timeout_aborts_body_read(self) -> None: @@ -294,5 +331,5 @@ async def bytes(self) -> bytes: with self.assertRaises(TransportError) as ctx: await self.transport.request(RequestMethod.GET, "https://example.test/", request_timeout=0.01) - assert isinstance(ctx.exception.caused_by, TimeoutError) + assert isinstance(ctx.exception.caused_by, asyncio.TimeoutError) assert fetch.last_call["signal"].aborted From 08c3f8465ac5b510a1468b8e59bc0f7ec4fb04d8 Mon Sep 17 00:00:00 2001 From: Eric Daniels <106169966+geostats-BSY@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:04:44 -0700 Subject: [PATCH 7/8] remove code-playground specific logic and method --- packages/evo-sdk-common/pyproject.toml | 8 - .../evo/notebooks/assets/service_manager.js | 40 -- .../src/evo/notebooks/widgets.py | 167 +----- .../evo-sdk-common/src/evo/oauth/__init__.py | 16 +- .../src/evo/oauth/authorizer.py | 3 +- .../src/evo/pyodide/__init__.py | 25 - .../src/evo/pyodide/_browser.py | 97 ---- .../src/evo/pyodide/transport.py | 186 ------- .../test_service_manager_frontend.mjs | 138 ++--- .../notebooks/test_service_manager_widget.py | 484 +----------------- .../tests/oauth/test_lazy_imports.py | 70 --- .../evo-sdk-common/tests/pyodide/__init__.py | 10 - .../evo-sdk-common/tests/pyodide/_stubs.py | 175 ------- .../tests/pyodide/test_browser.py | 125 ----- .../tests/pyodide/test_transport.py | 335 ------------ uv.lock | 13 +- 16 files changed, 109 insertions(+), 1783 deletions(-) delete mode 100644 packages/evo-sdk-common/src/evo/pyodide/__init__.py delete mode 100644 packages/evo-sdk-common/src/evo/pyodide/_browser.py delete mode 100644 packages/evo-sdk-common/src/evo/pyodide/transport.py delete mode 100644 packages/evo-sdk-common/tests/oauth/test_lazy_imports.py delete mode 100644 packages/evo-sdk-common/tests/pyodide/__init__.py delete mode 100644 packages/evo-sdk-common/tests/pyodide/_stubs.py delete mode 100644 packages/evo-sdk-common/tests/pyodide/test_browser.py delete mode 100644 packages/evo-sdk-common/tests/pyodide/test_transport.py diff --git a/packages/evo-sdk-common/pyproject.toml b/packages/evo-sdk-common/pyproject.toml index 0bd896fac..6284efc48 100644 --- a/packages/evo-sdk-common/pyproject.toml +++ b/packages/evo-sdk-common/pyproject.toml @@ -32,14 +32,6 @@ notebooks = [ "python-dotenv", "Pillow", ] -# For JupyterLite and other Pyodide runtimes. aiohttp is omitted because sockets are non-functional under WebAssembly, -# so requests go through the browser instead. The `pyodide` and `js` modules are provided by the runtime itself. -pyodide = [ - "anywidget", - "ipykernel", - "ipython", - "python-dotenv", -] jmespath = [ "jmespath>=1.0.1", ] 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 index 6af4ceb35..4fa2d79c1 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js +++ b/packages/evo-sdk-common/src/evo/notebooks/assets/service_manager.js @@ -54,52 +54,12 @@ function render({ model, el }) { renderLoading(); renderMessage(); - let tokenChannel = null; - const sendBrowserToken = (message) => { - if (tokenChannel) { - tokenChannel.postMessage(message); - } else { - model.send(message); - } - }; - - const requestBrowserToken = () => { - tokenChannel?.close(); - tokenChannel = null; - const channelId = model.get("browser_token_channel"); - if (!model.get("browser_token_required") || !channelId) { - return; - } - tokenChannel = typeof BroadcastChannel !== "undefined" - ? new BroadcastChannel(`evo-browser-token-${channelId}`) : null; - try { - const stored = localStorage.getItem("accessToken"); - if (!stored) { - throw new Error("No access token found in browser local storage. Sign in first."); - } - const token = JSON.parse(stored)?.access_token; - if (typeof token !== "string" || !token) { - throw new Error("The browser access token document is invalid."); - } - sendBrowserToken({ type: "browser_token", token }); - } catch (error) { - sendBrowserToken({ type: "browser_token", error: error instanceof SyntaxError - ? "The browser access token document is invalid." - : error instanceof Error ? error.message : "Cannot read the browser access token." }); - } - }; - - model.on("change:browser_token_channel", requestBrowserToken); - requestBrowserToken(); - 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); - model.off("change:browser_token_channel", requestBrowserToken); - tokenChannel?.close(); }; } diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py index bcf33086b..06de52306 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -13,21 +13,22 @@ import asyncio import contextlib -import sys from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast -from uuid import UUID, uuid4 +from typing import Any, Generic, TypeVar, cast +from uuid import UUID import anywidget import traitlets +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, IContext, IFeedback, ITransport from evo.discovery import Hub, Organization -from evo.oauth import AccessTokenAuthorizer, AnyScopes, EvoScopes, OAuthConnector +from evo.oauth import AnyScopes, EvoScopes, OAuthConnector from evo.oauth.exceptions import OAuthError from evo.service_manager import ServiceManager from evo.workspaces import Workspace @@ -42,9 +43,6 @@ from .authorizer import AuthorizationCodeAuthorizer from .env import DotEnv -if TYPE_CHECKING: - from aiohttp.typedefs import StrOrURL - T = TypeVar("T") logger = logging.getLogger(__name__) @@ -224,8 +222,6 @@ class ServiceManagerWidget(anywidget.AnyWidget, IContext, metaclass=_ServiceMana disabled = traitlets.Bool(False).tag(sync=True) loading = traitlets.Bool(False).tag(sync=True) message = traitlets.Unicode("").tag(sync=True) - browser_token_required = traitlets.Bool(False).tag(sync=True) - browser_token_channel = traitlets.Unicode("").tag(sync=True) def __init__( self, @@ -233,8 +229,6 @@ def __init__( authorizer: IAuthorizer, discovery_url: str, cache: ICache, - *, - browser_token: bool = False, ) -> None: """ :param transport: The transport to use for API requests. @@ -245,14 +239,6 @@ def __init__( self._widget_closed = False self._authorizer = authorizer self._cache = cache - self._browser_token_event = asyncio.Event() if browser_token else None - self._browser_token: str | None = None - self._browser_token_error: str | None = None - self._browser_channel = None - self._browser_channel_listener = None - browser_token_channel = ( - self._open_browser_token_channel() if browser_token and sys.platform == "emscripten" else "" - ) self._service_manager = ServiceManager( transport=transport, authorizer=authorizer, @@ -264,9 +250,7 @@ def __init__( super().__init__( logo=asset_data_uri("EvoBadgeCharcoal_FV.png"), spinner=asset_data_uri("loading.gif"), - button_text="Refresh Evo Services" if self._is_externally_authorized else "Sign In", - browser_token_required=browser_token, - browser_token_channel=browser_token_channel, + button_text="Sign In", ) self.on_msg(self._handle_frontend_msg) @@ -275,36 +259,9 @@ def __init__( display(self, self._org_selector, self._workspace_selector) - def _open_browser_token_channel(self) -> str: - from js import BroadcastChannel - from pyodide.ffi import create_proxy - - channel_id = uuid4().hex - channel = BroadcastChannel.new(f"evo-browser-token-{channel_id}") - listener = create_proxy(lambda message: self._handle_frontend_msg(self, message.data.to_py(), [])) - channel.addEventListener("message", listener) - self._browser_channel = channel - self._browser_channel_listener = listener - return channel_id - - def _close_browser_token_channel(self) -> None: - if getattr(self, "_browser_channel", None) is not None: - self._browser_channel.removeEventListener("message", self._browser_channel_listener) - self._browser_channel.close() - self._browser_channel_listener.destroy() - self._browser_channel = None - self._browser_channel_listener = None - def close(self) -> None: - """Release browser-token resources and close the widget, ending any pending token wait.""" + """Close the widget and prevent further use.""" self._widget_closed = True - self._close_browser_token_channel() - self.browser_token_channel = "" - event = getattr(self, "_browser_token_event", None) - if event is not None: - event.set() - self._browser_token = None - self._browser_token_error = None super().close() def _check_not_closed(self) -> None: @@ -345,8 +302,6 @@ def with_auth_code( :returns: The new ServiceManagerWidget. """ - from evo.aio import AioTransport - cache = init_cache(cache_location) transport = AioTransport(user_agent=client_id, proxy=proxy) authorizer = AuthorizationCodeAuthorizer( @@ -362,88 +317,6 @@ def with_auth_code( ) return cls(transport, authorizer, discovery_url, cache) - @classmethod - def with_access_token( - cls, - access_token: str | None = None, - discovery_url: str = DEFAULT_DISCOVERY_URL, - cache_location: FileName = DEFAULT_CACHE_LOCATION, - transport: ITransport | None = None, - user_agent: str = "evo-sdk-common", - ) -> ServiceManagerWidget: - """Create a ServiceManagerWidget from an access token that was issued elsewhere. - - This is intended for hosted environments, such as JupyterLite, where the page hosting the notebook has already - signed the user in. The token is not refreshed, so a new one must be supplied when it expires. - - ```python - manager = await ServiceManagerWidget.with_access_token().login() - ``` - - :param access_token: The access token to authorise requests with. When omitted in Pyodide, the widget frontend - reads it from the hosting page's local storage during login. If this fails, sign in on the hosting page - and call `login()` again on the same widget to retry. - :param discovery_url: The URL of the Evo Discovery service. - :param cache_location: The location of the cache file. - :param transport: The transport to use for API requests. Defaults to the browser `fetch` transport in a Pyodide - runtime, and to `AioTransport` elsewhere. - :param user_agent: The value to provide in the `User-Agent` header. - - :returns: The new ServiceManagerWidget. - """ - in_pyodide = sys.platform == "emscripten" - - if access_token is None: - if not in_pyodide: - raise ValueError("An access token must be provided when not running in a Pyodide runtime.") - - if transport is None: - if in_pyodide: - from evo.pyodide import JsTransport - - transport = JsTransport(user_agent=user_agent) - else: - from evo.aio import AioTransport - - transport = AioTransport(user_agent=user_agent) - - return cls( - transport, - AccessTokenAuthorizer(access_token or ""), - discovery_url, - init_cache(cache_location), - browser_token=access_token is None, - ) - - async def _receive_browser_token(self, timeout_seconds: int) -> None: - self._check_not_closed() - event = self._browser_token_event - if event is None: - return - if not self.browser_token_channel and sys.platform == "emscripten": - self.browser_token_channel = self._open_browser_token_channel() - try: - await asyncio.wait_for(event.wait(), timeout_seconds) - self._check_not_closed() - if self._browser_token_error: - raise OAuthError(self._browser_token_error) - if not self._browser_token: - raise OAuthError("No access token received from the hosting page. Sign in first.") - - self._authorizer = AccessTokenAuthorizer(self._browser_token) - self._service_manager._authorizer = self._authorizer - self._browser_token_event = None - except asyncio.TimeoutError as exc: - raise OAuthError( - "Timed out waiting for the browser access token. Display the widget and try again." - ) from exc - finally: - self._close_browser_token_channel() - self.browser_token_channel = "" - event.clear() - self._browser_token = None - self._browser_token_error = None - async def _login_with_auth_code(self, timeout_seconds: int) -> None: """Login using an authorization code authorizer. @@ -476,12 +349,9 @@ async def login(self, timeout_seconds: int = 180) -> 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(): - await self._receive_browser_token(timeout_seconds) match self._authorizer: case AuthorizationCodeAuthorizer(): await self._login_with_auth_code(timeout_seconds) - case AccessTokenAuthorizer(): - pass # The token was issued elsewhere, so there is nothing to do. case unknown: raise NotImplementedError(f"ServiceManagerWidget cannot login using {type(unknown).__name__}.") @@ -493,13 +363,8 @@ async def login(self, timeout_seconds: int = 180) -> ServiceManagerWidget: def cache(self) -> ICache: return self._cache - @property - def _is_externally_authorized(self) -> bool: - """Whether credentials come from outside the widget, so it cannot initiate a sign in itself.""" - return isinstance(self._authorizer, AccessTokenAuthorizer) - def _update_btn(self, signed_in: bool) -> None: - if signed_in or self._is_externally_authorized: + if signed_in: self.button_text = "Refresh Evo Services" else: self.button_text = "Sign In" @@ -508,13 +373,7 @@ def _handle_frontend_msg(self, _widget: object, content: object, _buffers: list) if self._widget_closed: return None if isinstance(content, dict) and content.get("type") == "click": - return asyncio.ensure_future( - self.login() if self._browser_token_event is not None else self.refresh_services() - ) - if isinstance(content, dict) and content.get("type") == "browser_token" and self._browser_token_event: - self._browser_token = content.get("token") if isinstance(content.get("token"), str) else None - self._browser_token_error = content.get("error") if isinstance(content.get("error"), str) else None - self._browser_token_event.set() + return asyncio.ensure_future(self.refresh_services()) return None @contextlib.contextmanager @@ -542,13 +401,7 @@ async def refresh_services(self) -> None: with self._loading_services(): try: await self._service_manager.refresh_organizations() - except UnauthorizedException as exc: # Expired token or user not logged in. - if isinstance(self._authorizer, AccessTokenAuthorizer): - raise OAuthError( - "The access token is no longer valid, and cannot be refreshed from here. Sign in again" - " and create a new ServiceManagerWidget with the new token." - ) from exc - + except UnauthorizedException: # Expired token or user not logged in. # Attempt to log in again. await self.login() diff --git a/packages/evo-sdk-common/src/evo/oauth/__init__.py b/packages/evo-sdk-common/src/evo/oauth/__init__.py index fc7dd5dbf..34c9fe66b 100644 --- a/packages/evo-sdk-common/src/evo/oauth/__init__.py +++ b/packages/evo-sdk-common/src/evo/oauth/__init__.py @@ -11,8 +11,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - from .authorizer import ( AccessTokenAuthorizer, AuthorizationCodeAuthorizer, @@ -21,9 +19,7 @@ from .connector import OAuthConnector from .data import AccessToken, AnyScopes, EvoScopes, OAuthScopes, Scopes from .exceptions import OAuthError - -if TYPE_CHECKING: - from .oauth_redirect_handler import OAuthRedirectHandler +from .oauth_redirect_handler import OAuthRedirectHandler __all__ = [ "AccessToken", @@ -38,13 +34,3 @@ "OAuthScopes", "Scopes", ] - - -# OAuthRedirectHandler serves the redirect over a local web server, so it needs the optional aiohttp extra. Resolve -# it on demand so the rest of this package stays usable without it. -def __getattr__(name: str) -> Any: - if name == "OAuthRedirectHandler": - from .oauth_redirect_handler import OAuthRedirectHandler - - return OAuthRedirectHandler - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/evo-sdk-common/src/evo/oauth/authorizer.py b/packages/evo-sdk-common/src/evo/oauth/authorizer.py index d624a48f8..91f8324a8 100644 --- a/packages/evo-sdk-common/src/evo/oauth/authorizer.py +++ b/packages/evo-sdk-common/src/evo/oauth/authorizer.py @@ -21,6 +21,7 @@ from .connector import OAuthConnector from .data import AccessToken, AnyScopes, EvoScopes, Scopes from .exceptions import OAuthError +from .oauth_redirect_handler import OAuthRedirectHandler __all__ = [ "AccessTokenAuthorizer", @@ -186,8 +187,6 @@ async def _handle_login(self, timeout_seconds: int) -> AccessToken: :raises OAuthError: If the user does not authenticate within the timeout. """ - from .oauth_redirect_handler import OAuthRedirectHandler - async with OAuthRedirectHandler(self._connector, self._redirect_url) as handler: return await handler.login(scopes=self._scopes, timeout_seconds=timeout_seconds) diff --git a/packages/evo-sdk-common/src/evo/pyodide/__init__.py b/packages/evo-sdk-common/src/evo/pyodide/__init__.py deleted file mode 100644 index 50fa9c597..000000000 --- a/packages/evo-sdk-common/src/evo/pyodide/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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. - -"""Support for running the Evo SDK inside a Pyodide runtime, such as JupyterLite. - -This package may only be imported from a Pyodide runtime. It provides a transport that routes requests through the -browser `fetch` API, and helpers for picking up credentials issued by the hosting page. -""" - -from ._browser import get_browser_access_token, get_browser_config -from .transport import JsTransport - -__all__ = [ - "JsTransport", - "get_browser_access_token", - "get_browser_config", -] diff --git a/packages/evo-sdk-common/src/evo/pyodide/_browser.py b/packages/evo-sdk-common/src/evo/pyodide/_browser.py deleted file mode 100644 index 354927768..000000000 --- a/packages/evo-sdk-common/src/evo/pyodide/_browser.py +++ /dev/null @@ -1,97 +0,0 @@ -# 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. - -from __future__ import annotations - -import base64 -import json -import time - -from evo.logging import getLogger -from evo.oauth.exceptions import OAuthError - -__all__ = [ - "get_browser_access_token", - "get_browser_config", -] - -logger = getLogger("pyodide.browser") - -DEFAULT_TOKEN_STORAGE_KEY = "accessToken" -DEFAULT_CONFIG_URL = "/config.json" - - -def _is_expired(token: str) -> bool: - """Best-effort expiry check, so an obviously stale token fails fast with a useful message. - - The signature is not verified and opaque tokens are treated as valid; the service remains the authority. - """ - try: - _, payload, _ = token.split(".") - padded = payload + "=" * (-len(payload) % 4) - expires_at = json.loads(base64.urlsafe_b64decode(padded))["exp"] - except Exception: - logger.debug("Could not read an expiry from the access token.", exc_info=True) - return False - return float(expires_at) <= time.time() - - -def get_browser_access_token(storage_key: str = DEFAULT_TOKEN_STORAGE_KEY) -> str: - """Read the access token that the hosting page has placed in browser local storage. - - The hosting page is expected to store a JSON document containing an `access_token` field. - - :param storage_key: The local storage key the hosting page stores the token under. - - :returns: The access token. - - :raises OAuthError: If no usable, unexpired token is available. - """ - try: - from js import localStorage - except ImportError: - raise ImportError("Browser credentials are only available when running in a Pyodide runtime.") - - raw_token = localStorage.getItem(storage_key) - if not raw_token: - raise OAuthError(f"No access token found in browser local storage under {storage_key!r}. Sign in first.") - - try: - access_token = json.loads(raw_token)["access_token"] - except (ValueError, KeyError, TypeError): - raise OAuthError(f"The value stored under {storage_key!r} is not a valid access token document.") - - if not isinstance(access_token, str) or not access_token: - raise OAuthError(f"The value stored under {storage_key!r} does not contain an access token.") - - if _is_expired(access_token): - raise OAuthError("The access token provided by the host page has expired. Sign in again.") - - return access_token - - -async def get_browser_config(config_url: str = DEFAULT_CONFIG_URL) -> dict: - """Read the configuration document published by the hosting page. - - :param config_url: The URL of the configuration document. - - :returns: The parsed configuration, or an empty mapping if it is unavailable. - """ - from pyodide.http import pyfetch - - try: - response = await pyfetch(config_url) - if not response.ok: - raise OAuthError(f"Unexpected status {response.status} fetching {config_url}.") - return await response.json() - except Exception: - logger.debug(f"Could not load host configuration from {config_url}.", exc_info=True) - return {} diff --git a/packages/evo-sdk-common/src/evo/pyodide/transport.py b/packages/evo-sdk-common/src/evo/pyodide/transport.py deleted file mode 100644 index de10a1fce..000000000 --- a/packages/evo-sdk-common/src/evo/pyodide/transport.py +++ /dev/null @@ -1,186 +0,0 @@ -# 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. - -from __future__ import annotations - -try: - from js import AbortController, Blob, FormData - - from pyodide.ffi import to_js - from pyodide.http import pyfetch -except ImportError: - raise ImportError("JsTransport cannot be used because it is not running in a Pyodide runtime.") - -import asyncio -import json -import re -from time import monotonic -from types import TracebackType -from urllib.parse import urlencode, urlsplit - -from evo.common import HTTPHeaderDict, HTTPResponse, RequestMethod -from evo.common.exceptions import ClientValueError, TransportError -from evo.common.interfaces import ITransport -from evo.logging import getLogger - -__all__ = ["JsTransport"] - -logger = getLogger("pyodide.transport") - -_FORM_CONTENT_TYPES = ("application/x-www-form-urlencoded", "multipart/form-data") - - -class JsTransport(ITransport): - """An `ITransport` implementation that issues requests through the browser `fetch` API. - - See `evo.common.interfaces.ITransport` for more detail. - - Unlike other transports, this one cannot avoid following redirects: the browser exposes manually handled redirects - as opaque responses with status 0. Redirects are therefore followed, and the responses the SDK relies on are - reconstructed from the final response. - """ - - def __init__(self, user_agent: str | None = None) -> None: - """ - :param user_agent: The value to provide in the `User-Agent` header. - """ - self.user_agent = user_agent or "evo-sdk-common" - - async def open(self) -> None: - pass - - async def close(self) -> None: - pass - - async def __aenter__(self) -> ITransport: - return self - - async def __aexit__( - self, - exc_type: type[Exception] | None, - exc_val: Exception | None, - exc_tb: TracebackType | None, - ) -> None: - pass - - def _build_body( - self, - headers: dict[str, str], - post_params: list[tuple[str, str | bytes]], - body: object | str | bytes | None, - ) -> object: - content_type = headers.get("Content-Type", "") - if post_params and any(form_type in content_type for form_type in _FORM_CONTENT_TYPES): - if content_type == "application/x-www-form-urlencoded": - return urlencode(post_params) - headers.pop("Content-Type") - form_data = FormData.new() - for key, value in post_params: - form_data.append(key, Blob.new(to_js([value])) if isinstance(value, bytes) else value) - return form_data - - if body is not None and not isinstance(body, (str, bytes)): - headers.setdefault("Content-Type", "application/json") - return json.dumps(body) - - return body - - async def request( - self, - method: RequestMethod, - url: str, - headers: HTTPHeaderDict | None = None, - post_params: list[tuple[str, str | bytes]] | None = None, - body: object | str | bytes | None = None, - request_timeout: int | float | tuple[int | float, int | float] | None = None, - ) -> HTTPResponse: - if post_params is not None and body is not None: - raise ClientValueError(msg="HTTP body and post parameters cannot be used at the same time.") - - controller = AbortController.new() - deadline = monotonic() + request_timeout if isinstance(request_timeout, (int, float)) else None - connect_timeout, read_timeout = request_timeout if isinstance(request_timeout, tuple) else (None, None) - - def timeout_for(stage: str) -> float | None: - if deadline is not None: - return max(0, deadline - monotonic()) - return connect_timeout if stage == "connect" else read_timeout - - async def fetch(fetch_url: str, **options: object) -> object: - return await asyncio.wait_for( - pyfetch(fetch_url, signal=controller.signal, **options), timeout=timeout_for("connect") - ) - - try: - request_headers = dict(headers or {}) - request_headers.setdefault("User-Agent", self.user_agent) - request_body = self._build_body(request_headers, post_params or [], body) - - is_head = method == RequestMethod.HEAD - resp = await fetch( - url, - method=str(method), - headers=request_headers, - body=request_body, - redirect="follow", - ) - resp_headers = HTTPHeaderDict(resp.headers) - - if ( - is_head - and resp.status == 200 - and ("Content-Length" not in resp_headers or "Accept-Ranges" not in resp_headers) - ): - probe_headers = {**request_headers, "Range": "bytes=0-0"} - probe = await fetch(url, method="GET", headers=probe_headers, redirect="follow") - content_range = HTTPHeaderDict(probe.headers).get("Content-Range", "") - match = re.fullmatch(r"bytes 0-0/(\d+)", content_range) - if probe.status == 206 and match: - if "Content-Length" not in resp_headers: - resp_headers["Content-Length"] = match.group(1) - if "Accept-Ranges" not in resp_headers: - resp_headers["Accept-Ranges"] = "bytes" - - if resp.redirected: - request_path = urlsplit(url).path.rstrip("/") - status_path = urlsplit(str(resp.url)).path - if ( - method == RequestMethod.POST - and resp.status in (200, 202) - and re.fullmatch(r".*/compute/orgs/[^/]+/[^/]+/[^/]+", request_path) - and re.fullmatch(re.escape(request_path) + r"/[^/]+/status", status_path) - ): - resp_headers["Location"] = str(resp.url) - return HTTPResponse(status=303, data=b"", reason="See Other", headers=resp_headers) - raise TransportError(msg="Browser fetch followed a redirect whose original response is unavailable.") - - # Redirect responses carry their information in the Location header and have no body to read. - if 300 <= resp.status < 400: - return HTTPResponse(status=resp.status, data=b"", reason=resp.status_text, headers=resp_headers) - - if is_head: - return HTTPResponse(status=resp.status, data=b"", reason=resp.status_text, headers=resp_headers) - - data = await asyncio.wait_for(resp.bytes(), timeout=timeout_for("read")) - range_header = request_headers.get("Range") or request_headers.get("range") - if range_header and resp.status == 206 and "Content-Range" not in resp_headers: - if match := re.fullmatch(r"bytes=(\d+)-(\d+)", range_header): - start, end = map(int, match.groups()) - if end >= start and len(data) == end - start + 1: - resp_headers["Content-Range"] = f"bytes {start}-{end}/*" - - return HTTPResponse(status=resp.status, data=data, reason=resp.status_text, headers=resp_headers) - except TransportError: - raise - except Exception as e: - raise TransportError(msg="Could not complete HTTP request", caused_by=e) - finally: - controller.abort() 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 index 8ea37c59c..53fd1011c 100644 --- a/packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_frontend.mjs @@ -5,69 +5,85 @@ 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("browser token retries reread storage and clean up channels and listeners", () => { - for (const useBroadcastChannel of [true, false]) { - const channels = []; - const messages = []; - const handlers = new Map(); - const values = { - browser_token_required: true, - browser_token_channel: "first", - }; - let storedToken = null; - globalThis.document = { - createElement: () => ({ +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: {}, - append() {}, - addEventListener() {}, - removeEventListener() {}, - }), - }; - globalThis.localStorage = { getItem: () => storedToken }; - globalThis.BroadcastChannel = useBroadcastChannel ? class { - constructor(name) { - this.name = name; - this.closed = false; - channels.push(this); - } - postMessage(message) { - assert.equal(this.closed, false); - messages.push(message); - } - close() { - this.closed = true; - } - } : undefined; - 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); - }, - }; - const cleanup = render({ model, el: { appendChild() {} } }); - try { - assert.match(messages[0].error, /Sign in first/); - values.browser_token_channel = ""; - handlers.get("change:browser_token_channel")(); - assert.equal(messages.length, 1); - storedToken = JSON.stringify({ access_token: "retry-token" }); - values.browser_token_channel = "retry"; - handlers.get("change:browser_token_channel")(); - assert.deepEqual(messages[1], { type: "browser_token", token: "retry-token" }); - if (useBroadcastChannel) { - assert.equal(channels[0].closed, true); - assert.equal(channels[1].name, "evo-browser-token-retry"); - } - } finally { - cleanup(); + 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; - delete globalThis.localStorage; - delete globalThis.BroadcastChannel; + } else { + globalThis.document = originalDocument; } - assert.equal(handlers.size, 0); - assert.ok(channels.every((channel) => channel.closed)); } }); 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 index abac7c98b..79db4aa2f 100644 --- a/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -9,498 +9,52 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio -import json -import sys import tempfile import unittest from unittest import mock -from uuid import UUID -from evo.aio import AioTransport -from evo.common import RequestMethod -from evo.common.exceptions import SelectionError +from anywidget import AnyWidget + from evo.common.interfaces import IContext -from evo.common.test_tools import MockResponse, TestTransport from evo.notebooks import ServiceManagerWidget -from evo.oauth import AccessTokenAuthorizer from evo.oauth.exceptions import OAuthError -from ..data import load_test_data - -DISCOVERY_URL = "https://discover.test" -EMPTY_WORKSPACE_LIST = json.dumps( - { - "results": [], - "links": { - "first": "http://first", - "last": "http://last", - "next": None, - "previous": None, - "count": 0, - "total": 0, - }, - } -) - -class _ServiceManagerWidgetTestCase(unittest.IsolatedAsyncioTestCase): +class TestServiceManagerWidget(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) - self.cache_location = self._tmp.name - - def build_widget(self, transport: TestTransport | None = None) -> ServiceManagerWidget: - return ServiceManagerWidget.with_access_token( - "test-token", - discovery_url=DISCOVERY_URL, - cache_location=self.cache_location, - transport=transport or TestTransport(base_url=DISCOVERY_URL), - ) - - -class TestWithAccessToken(_ServiceManagerWidgetTestCase): - def test_uses_access_token_authorizer(self) -> None: - widget = self.build_widget() - assert isinstance(widget._authorizer, AccessTokenAuthorizer) - - async def test_authorizes_requests_with_the_token(self) -> None: - widget = self.build_widget() - headers = await widget._authorizer.get_default_headers() - assert headers == {"Authorization": "Bearer test-token"} - - def test_is_a_context(self) -> None: - """The playground widget was only duck-typed, so this is the main benefit of unifying the two.""" - assert isinstance(self.build_widget(), IContext) - - def test_honours_an_explicit_transport(self) -> None: - transport = TestTransport(base_url=DISCOVERY_URL) - widget = self.build_widget(transport) - assert widget._service_manager._transport is transport - - def test_defaults_to_aiohttp_outside_pyodide(self) -> None: - widget = ServiceManagerWidget.with_access_token("test-token", cache_location=self.cache_location) - assert isinstance(widget._service_manager._transport, AioTransport) - - def test_requires_a_token_outside_pyodide(self) -> None: - with self.assertRaisesRegex(ValueError, "access token must be provided"): - ServiceManagerWidget.with_access_token(cache_location=self.cache_location) - - async def test_receives_the_token_from_the_frontend_in_pyodide(self) -> None: - transport = TestTransport(base_url=DISCOVERY_URL) - - async def handler(*, url: str, **kwargs: object) -> MockResponse: - content = ( - json.dumps(load_test_data("successful_service_discovery.json")) - if "discovery" in url - else EMPTY_WORKSPACE_LIST - ) - return MockResponse(status_code=200, content=content, headers={"Content-Type": "application/json"}) - - transport.request.side_effect = handler - with ( - mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - mock.patch.object(ServiceManagerWidget, "_open_browser_token_channel", return_value="test"), - ): - widget = ServiceManagerWidget.with_access_token( - discovery_url=DISCOVERY_URL, cache_location=self.cache_location, transport=transport - ) - - widget._handle_frontend_msg(widget, {"type": "browser_token", "token": "browser-token"}, []) - await widget.login() - assert widget.organizations - assert isinstance(widget._authorizer, AccessTokenAuthorizer) - assert await widget._authorizer.get_default_headers() == {"Authorization": "Bearer browser-token"} - - async def test_chained_login_receives_browser_token_during_the_same_cell(self) -> None: - transport = TestTransport(base_url=DISCOVERY_URL) - - async def handler(*, url: str, **kwargs: object) -> MockResponse: - content = ( - json.dumps(load_test_data("successful_service_discovery.json")) - if "discovery" in url - else EMPTY_WORKSPACE_LIST - ) - return MockResponse(status_code=200, content=content, headers={"Content-Type": "application/json"}) - - transport.request.side_effect = handler - channel = mock.Mock() - browser = mock.Mock() - browser.BroadcastChannel.new.return_value = channel - ffi = mock.Mock() - ffi.create_proxy.side_effect = lambda callback: mock.Mock(side_effect=callback) - - with ( - mock.patch.dict(sys.modules, {"js": browser, "pyodide": mock.Mock(), "pyodide.ffi": ffi}), - mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - ): - widget = ServiceManagerWidget.with_access_token( - discovery_url=DISCOVERY_URL, cache_location=self.cache_location, transport=transport - ) - login_task = asyncio.create_task(widget.login()) - await asyncio.sleep(0) - listener = channel.addEventListener.call_args.args[1] - listener(mock.Mock(data=mock.Mock(to_py=lambda: {"type": "browser_token", "token": "browser-token"}))) - await login_task - - assert widget.organizations - assert await widget._authorizer.get_default_headers() == {"Authorization": "Bearer browser-token"} - channel.removeEventListener.assert_called_once() - channel.close.assert_called_once() - listener.destroy.assert_called_once() - - async def test_missing_browser_token_reports_sign_in(self) -> None: - with ( - mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - mock.patch.object(ServiceManagerWidget, "_open_browser_token_channel", return_value="test"), - ): - widget = ServiceManagerWidget.with_access_token( - cache_location=self.cache_location, transport=TestTransport(base_url=DISCOVERY_URL) - ) - widget._handle_frontend_msg(widget, {"type": "browser_token", "error": "Sign in first."}, []) - with self.assertRaisesRegex(OAuthError, "Sign in first"): - await widget.login() - - async def test_browser_token_times_out_without_a_frontend(self) -> None: - with ( - mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - mock.patch.object(ServiceManagerWidget, "_open_browser_token_channel", return_value="test"), - ): - widget = ServiceManagerWidget.with_access_token( - cache_location=self.cache_location, transport=TestTransport(base_url=DISCOVERY_URL) - ) - with self.assertRaisesRegex(OAuthError, "Timed out waiting"): - await widget.login(timeout_seconds=0) - - async def test_browser_token_login_can_retry_after_failure(self) -> None: - for failure in ("timeout", "error", "empty", "cancel"): - with self.subTest(failure=failure): - browser = mock.Mock() - channels = [mock.Mock(), mock.Mock()] - browser.BroadcastChannel.new.side_effect = channels - ffi = mock.Mock() - ffi.create_proxy.side_effect = lambda callback: mock.Mock(side_effect=callback) - - with ( - mock.patch.dict(sys.modules, {"js": browser, "pyodide": mock.Mock(), "pyodide.ffi": ffi}), - mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - ): - widget = ServiceManagerWidget.with_access_token( - cache_location=self.cache_location, transport=TestTransport(base_url=DISCOVERY_URL) - ) - original_channel_id = widget.browser_token_channel - first_listener = channels[0].addEventListener.call_args.args[1] - if failure in ("error", "empty"): - content = {"type": "browser_token"} - if failure == "error": - content["error"] = "Sign in first." - first_listener(mock.Mock(data=mock.Mock(to_py=lambda: content))) - if failure == "cancel": - task = asyncio.create_task(widget.login()) - await asyncio.sleep(0) - task.cancel() - with self.assertRaises(asyncio.CancelledError): - await task - else: - with self.assertRaises(OAuthError): - await widget.login(timeout_seconds=0 if failure == "timeout" else 1) - - channels[0].close.assert_called_once() - first_listener.destroy.assert_called_once() - with mock.patch.object(widget, "refresh_services", new_callable=mock.AsyncMock): - retry = asyncio.create_task(widget.login(timeout_seconds=1)) - try: - await asyncio.sleep(0) - assert widget.browser_token_channel != original_channel_id - assert browser.BroadcastChannel.new.call_count == 2 - listener = channels[1].addEventListener.call_args.args[1] - listener( - mock.Mock( - data=mock.Mock(to_py=lambda: {"type": "browser_token", "token": "retry-token"}) - ) - ) - assert await retry is widget - finally: - if not retry.done(): - retry.cancel() - with self.assertRaises(asyncio.CancelledError): - await retry - - assert widget._browser_token_event is None - assert widget._browser_token_error is None - channels[1].close.assert_called_once() - listener.destroy.assert_called_once() - headers = await widget._authorizer.get_default_headers() - assert headers["Authorization"] == "Bearer retry-token" - - def test_explicit_token_never_requests_browser_storage(self) -> None: - widget = self.build_widget() - assert not widget.browser_token_required - assert widget._browser_token_event is None - - def test_button_invites_a_refresh_rather_than_a_sign_in(self) -> None: - assert self.build_widget().button_text == "Refresh Evo Services" - - def test_button_invites_a_sign_in_for_the_auth_code_flow(self) -> None: - widget = ServiceManagerWidget.with_auth_code(client_id="test", cache_location=self.cache_location) - assert widget.button_text == "Sign In" - - def test_does_not_persist_the_token(self) -> None: - """Unlike the auth code flow, an externally issued token must not be written to the cache.""" - self.build_widget() - written = [path.read_text() for path in self._tmp_files()] - assert not any("test-token" in content for content in written) - - def _tmp_files(self) -> list: - from pathlib import Path - - return [path for path in Path(self.cache_location).rglob("*") if path.is_file()] - - -class TestLoginWithAccessToken(_ServiceManagerWidgetTestCase): - def setUp(self) -> None: - super().setUp() - self.transport = TestTransport(base_url=DISCOVERY_URL) - self.discovery_data = json.dumps(load_test_data("successful_service_discovery.json")) - self.widget = self.build_widget(self.transport) + self.widget = ServiceManagerWidget.with_auth_code(client_id="test", cache_location=self._tmp.name) + self.addCleanup(self.widget.close) - def _respond(self, discovery_status: int = 200) -> None: - async def handler(*, method: RequestMethod, url: str, **kwargs: object) -> MockResponse: - if "discovery" in url: - return MockResponse( - status_code=discovery_status, - content=self.discovery_data if discovery_status == 200 else "", - headers={"Content-Type": "application/json"}, - ) - return MockResponse( - status_code=200, content=EMPTY_WORKSPACE_LIST, headers={"Content-Type": "application/json"} - ) + def test_is_anywidget_and_context(self) -> None: + assert isinstance(self.widget, AnyWidget) + assert isinstance(self.widget, IContext) - self.transport.request.side_effect = handler + def test_sign_in_button_updates_for_auth_state(self) -> None: + assert self.widget.button_text == "Sign In" - async def test_login_does_not_require_an_interactive_flow(self) -> None: - self._respond() - assert await self.widget.login() is self.widget + self.widget._update_btn(True) + assert self.widget.button_text == "Refresh Evo Services" - async def test_login_refreshes_the_service_list(self) -> None: - self._respond() - await self.widget.login() - assert self.widget.organizations + self.widget._update_btn(False) + assert self.widget.button_text == "Sign In" - async def test_explicit_token_click_refreshes_without_logging_in(self) -> None: - self._respond() - with mock.patch.object(self.widget, "login", new_callable=mock.AsyncMock) as login: + 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 - assert self.widget.organizations - login.assert_not_awaited() - - def _select_first_real_org(self): - # The first org in the test data uses the nil UUID, which is also the selector's "unselected" sentinel. - org = next(org for org in self.widget.organizations if org.id != UUID(int=0)) - self.widget._org_selector.value = str(org.id) - return org + refresh.assert_awaited_once() - async def test_login_selects_an_organization_and_hub(self) -> None: - self._respond() - await self.widget.login() - - org = self._select_first_real_org() - - assert self.widget.get_org_id() == org.id - assert isinstance(self.widget.get_org_id(), UUID) - assert self.widget.hubs - - async def test_get_environment_requires_a_workspace(self) -> None: - self._respond() - await self.widget.login() - self._select_first_real_org() - - with self.assertRaises(SelectionError): - self.widget.get_environment() - - async def test_connector_targets_the_selected_hub(self) -> None: - self._respond() - await self.widget.login() - self._select_first_real_org() - - connector = self.widget.get_connector() - assert connector.base_url.startswith(self.widget.hubs[0].url) - - async def test_expired_token_reports_that_it_cannot_be_refreshed(self) -> None: - """A token issued elsewhere cannot be renewed here, so this must not recurse into login().""" - self._respond(discovery_status=401) - with self.assertRaisesRegex(OAuthError, "cannot be refreshed from here"): - await self.widget.login() - - async def test_unsupported_authorizer_is_rejected(self) -> None: - self.widget._authorizer = mock.Mock() - with self.assertRaises(NotImplementedError): - await self.widget.login() - - -class TestBrowserTokenLifecycle(_ServiceManagerWidgetTestCase): - def setUp(self) -> None: - super().setUp() - self.channels = [mock.Mock(), mock.Mock()] - browser = mock.Mock() - browser.BroadcastChannel.new.side_effect = self.channels - ffi = mock.Mock() - ffi.create_proxy.side_effect = lambda callback: mock.Mock(side_effect=callback) - for patcher in ( - mock.patch.dict(sys.modules, {"js": browser, "pyodide": mock.Mock(), "pyodide.ffi": ffi}), - mock.patch("evo.notebooks.widgets.sys.platform", "emscripten"), - ): - patcher.start() - self.addCleanup(patcher.stop) - - self.transport = TestTransport(base_url=DISCOVERY_URL) - - async def handler(*, url: str, **kwargs: object) -> MockResponse: - content = ( - json.dumps(load_test_data("successful_service_discovery.json")) - if "discovery" in url - else EMPTY_WORKSPACE_LIST - ) - return MockResponse(status_code=200, content=content, headers={"Content-Type": "application/json"}) - - self.transport.request.side_effect = handler - self.widget = ServiceManagerWidget.with_access_token( - discovery_url=DISCOVERY_URL, cache_location=self.cache_location, transport=self.transport - ) - self.addCleanup(self.widget.close) - - def send_browser_message(self, content: dict[str, str], channel_index: int = 0) -> None: - listener = self.channels[channel_index].addEventListener.call_args.args[1] - listener(mock.Mock(data=mock.Mock(to_py=lambda: content))) - - def assert_channel_closed(self, channel_index: int = 0) -> None: - channel = self.channels[channel_index] - listener = channel.addEventListener.call_args.args[1] - channel.removeEventListener.assert_called_once_with("message", listener) - channel.close.assert_called_once() - listener.destroy.assert_called_once() - - async def test_click_installs_already_received_token_before_discovery(self) -> None: - self.send_browser_message({"type": "browser_token", "token": "browser-token"}) - - task = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) - assert task is not None - await task - - assert self.widget.organizations - expected = await AccessTokenAuthorizer("browser-token").get_default_headers() - for call in self.transport.request.await_args_list: - assert call.kwargs["headers"]["Authorization"] == expected["Authorization"] - discovery_calls = [call for call in self.transport.request.await_args_list if "discovery" in call.kwargs["url"]] - assert len(discovery_calls) == 1 - self.assert_channel_closed() - - async def test_click_waits_for_browser_token_before_discovery(self) -> None: - task = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) - assert task is not None - try: - await asyncio.sleep(0) - self.transport.request.assert_not_awaited() - self.send_browser_message({"type": "browser_token", "token": "browser-token"}) - await asyncio.wait_for(task, 1) - finally: - if not task.done(): - task.cancel() - with self.assertRaises(asyncio.CancelledError): - await task - - assert self.widget.organizations - assert not self.widget.loading - assert not self.widget.disabled - - async def test_click_retries_browser_token_failure(self) -> None: - self.send_browser_message({"type": "browser_token", "error": "Sign in first."}) - first = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) - assert first is not None - with self.assertRaisesRegex(OAuthError, "Sign in first"): - await first - self.transport.request.assert_not_awaited() - assert not self.widget.loading - assert not self.widget.disabled - self.assert_channel_closed() - - retry = self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) - assert retry is not None - try: - await asyncio.sleep(0) - self.send_browser_message({"type": "browser_token", "token": "retry-token"}, channel_index=1) - await asyncio.wait_for(retry, 1) - finally: - if not retry.done(): - retry.cancel() - with self.assertRaises(asyncio.CancelledError): - await retry - assert self.widget.organizations - self.assert_channel_closed(channel_index=1) - - async def test_close_releases_channel_and_rejects_reuse(self) -> None: + async def test_close_prevents_reuse(self) -> None: self.widget.close() self.widget.close() - self.assert_channel_closed() assert self.widget.comm is None - assert self.widget.browser_token_channel == "" for operation in (self.widget.login, self.widget.refresh_services): with self.subTest(operation=operation.__name__): with self.assertRaisesRegex(OAuthError, "closed"): await operation() - self.transport.open.assert_not_awaited() - self.transport.request.assert_not_awaited() - self.transport.close.assert_not_awaited() - - for content in ({"type": "click"}, {"type": "browser_token", "token": "late-token"}): - assert self.widget._handle_frontend_msg(self.widget, content, []) is None - assert self.widget._browser_token is None - self.channels[1].addEventListener.assert_not_called() - - async def test_close_promptly_ends_pending_login(self) -> None: - task = asyncio.create_task(self.widget.login()) - try: - await asyncio.sleep(0) - assert not task.done() - self.widget.close() - - with self.assertRaisesRegex(OAuthError, "closed"): - await asyncio.wait_for(task, 1) - finally: - if not task.done(): - task.cancel() - with self.assertRaises(asyncio.CancelledError): - await task - - self.assert_channel_closed() - self.transport.request.assert_not_awaited() - assert self.widget._browser_token is None - assert self.widget._browser_token_error is None - assert not self.widget.loading - - async def test_close_after_login_does_not_repeat_channel_cleanup(self) -> None: - self.send_browser_message({"type": "browser_token", "token": "browser-token"}) - await self.widget.login() - - self.widget.close() - self.widget.close() - - self.assert_channel_closed() - - def test_close_after_partial_initialization_releases_channel(self) -> None: - widget = ServiceManagerWidget.__new__(ServiceManagerWidget) - self.addCleanup(widget.close) - with mock.patch("evo.notebooks.widgets.ServiceManager", side_effect=ValueError("Initialization failed")): - with self.assertRaisesRegex(ValueError, "Initialization failed"): - widget.__init__( - self.transport, AccessTokenAuthorizer(""), DISCOVERY_URL, self.widget.cache, browser_token=True - ) - - widget.close() - widget.close() - - self.assert_channel_closed(channel_index=1) + assert self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) is None diff --git a/packages/evo-sdk-common/tests/oauth/test_lazy_imports.py b/packages/evo-sdk-common/tests/oauth/test_lazy_imports.py deleted file mode 100644 index 2d641df5f..000000000 --- a/packages/evo-sdk-common/tests/oauth/test_lazy_imports.py +++ /dev/null @@ -1,70 +0,0 @@ -# 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 builtins -import subprocess -import sys -import unittest - -import evo.oauth - - -class TestLazyRedirectHandler(unittest.TestCase): - """`OAuthRedirectHandler` runs a local web server, so it must not drag aiohttp into every import.""" - - def test_redirect_handler_resolves_on_demand(self) -> None: - from evo.oauth.oauth_redirect_handler import OAuthRedirectHandler - - assert evo.oauth.OAuthRedirectHandler is OAuthRedirectHandler - - def test_redirect_handler_is_exported(self) -> None: - assert "OAuthRedirectHandler" in evo.oauth.__all__ - - def test_unknown_attribute_raises(self) -> None: - with self.assertRaises(AttributeError): - evo.oauth.NotAThing - - -class TestImportWithoutAiohttp(unittest.TestCase): - """aiohttp is an optional extra, so importing the notebook widgets must not require it.""" - - def _import_with_aiohttp_blocked(self, module: str) -> subprocess.CompletedProcess: - script = ( - "import builtins\n" - "_real = builtins.__import__\n" - "def _guard(name, *args, **kwargs):\n" - " if name.split('.')[0] == 'aiohttp':\n" - " raise ImportError('aiohttp is unavailable')\n" - " return _real(name, *args, **kwargs)\n" - "builtins.__import__ = _guard\n" - f"import {module}\n" - ) - return subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) - - def test_evo_oauth_imports_without_aiohttp(self) -> None: - result = self._import_with_aiohttp_blocked("evo.oauth") - assert result.returncode == 0, result.stderr - - def test_evo_notebooks_imports_without_aiohttp(self) -> None: - result = self._import_with_aiohttp_blocked("evo.notebooks") - assert result.returncode == 0, result.stderr - - def test_aiohttp_is_not_imported_as_a_side_effect(self) -> None: - script = "import evo.notebooks, sys; print('aiohttp' in sys.modules)" - result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "False" - - def test_guard_actually_blocks_aiohttp(self) -> None: - """Guard against the other tests passing because the import hook is broken.""" - assert builtins.__import__ is not None - result = self._import_with_aiohttp_blocked("evo.aio") - assert result.returncode != 0 diff --git a/packages/evo-sdk-common/tests/pyodide/__init__.py b/packages/evo-sdk-common/tests/pyodide/__init__.py deleted file mode 100644 index bc5bffd31..000000000 --- a/packages/evo-sdk-common/tests/pyodide/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# 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/pyodide/_stubs.py b/packages/evo-sdk-common/tests/pyodide/_stubs.py deleted file mode 100644 index bee806747..000000000 --- a/packages/evo-sdk-common/tests/pyodide/_stubs.py +++ /dev/null @@ -1,175 +0,0 @@ -# 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. - -"""Stand-ins for the `js` and `pyodide` modules that only exist inside a Pyodide runtime. - -`evo.pyodide` cannot be imported without them, so `install()` must be called before importing anything from it. -""" - -from __future__ import annotations - -import base64 -import importlib.machinery -import json -import sys -import time -import types -from typing import Any - - -class FakeFormData: - """Stand-in for the browser `FormData` object.""" - - def __init__(self) -> None: - self.fields: list[tuple[str, str | FakeBlob]] = [] - - @classmethod - def new(cls) -> FakeFormData: - return cls() - - def append(self, key: str, value: str | FakeBlob) -> None: - self.fields.append((key, value)) - - -class FakeBlob: - def __init__(self, parts: list[bytes]) -> None: - self.parts = parts - - @classmethod - def new(cls, parts: list[bytes]) -> FakeBlob: - return cls(parts) - - -class FakeAbortController: - def __init__(self) -> None: - self.signal = self - self.aborted = False - - @classmethod - def new(cls) -> FakeAbortController: - return cls() - - def abort(self) -> None: - self.aborted = True - - -class FakeLocalStorage: - """Stand-in for the browser `localStorage` object.""" - - def __init__(self, items: dict[str, str] | None = None) -> None: - self._items = dict(items or {}) - - def getItem(self, key: str) -> str | None: # noqa: N802 - mirrors the browser API. - return self._items.get(key) - - def setItem(self, key: str, value: str) -> None: # noqa: N802 - mirrors the browser API. - self._items[key] = value - - -class FakeResponse: - """Stand-in for the response object returned by `pyodide.http.pyfetch`.""" - - def __init__( - self, - status: int = 200, - headers: dict[str, str] | None = None, - body: bytes = b"", - url: str = "https://example.test/", - redirected: bool = False, - status_text: str = "OK", - ) -> None: - self.status = status - self.headers = dict(headers or {}) - self.url = url - self.redirected = redirected - self.status_text = status_text - self.body_read = False - self._body = body - - @property - def ok(self) -> bool: - return 200 <= self.status < 300 - - async def bytes(self) -> bytes: - self.body_read = True - return self._body - - async def json(self) -> Any: - self.body_read = True - return json.loads(self._body) - - -class FakeFetch: - """Stand-in for `pyodide.http.pyfetch` that records calls and returns canned responses.""" - - def __init__(self, *responses: FakeResponse, error: Exception | None = None) -> None: - self._responses = list(responses) - self._error = error - self.calls: list[tuple[str, dict[str, Any]]] = [] - - @property - def last_call(self) -> dict[str, Any]: - return self.calls[-1][1] - - async def __call__(self, url: str, **kwargs: Any) -> FakeResponse: - self.calls.append((url, kwargs)) - if self._error is not None: - raise self._error - return self._responses.pop(0) if len(self._responses) > 1 else self._responses[0] - - -def jwt(expires_at: float) -> str: - """Build an unsigned JWT carrying only an `exp` claim.""" - payload = base64.urlsafe_b64encode(json.dumps({"exp": expires_at}).encode()).decode().rstrip("=") - return f"header.{payload}.signature" - - -def valid_jwt() -> str: - return jwt(time.time() + 600) - - -def expired_jwt() -> str: - return jwt(time.time() - 10) - - -def _new_module(name: str) -> types.ModuleType: - module = types.ModuleType(name) - # find_spec() and importlib machinery reject modules without a spec. - module.__spec__ = importlib.machinery.ModuleSpec(name, None) - return module - - -def install() -> None: - """Register the stand-in runtime modules so that `evo.pyodide` can be imported.""" - if "js" not in sys.modules: - js = _new_module("js") - js.AbortController = FakeAbortController - js.Blob = FakeBlob - js.FormData = FakeFormData - js.localStorage = FakeLocalStorage() - sys.modules["js"] = js - - if "pyodide" not in sys.modules: - pyodide = _new_module("pyodide") - pyodide.__path__ = [] - ffi = _new_module("pyodide.ffi") - ffi.to_js = lambda value: value - http = _new_module("pyodide.http") - - async def pyfetch(*args: Any, **kwargs: Any) -> FakeResponse: - raise AssertionError("pyfetch must be patched by the test.") - - http.pyfetch = pyfetch - pyodide.ffi = ffi - pyodide.http = http - sys.modules["pyodide"] = pyodide - sys.modules["pyodide.ffi"] = ffi - sys.modules["pyodide.http"] = http diff --git a/packages/evo-sdk-common/tests/pyodide/test_browser.py b/packages/evo-sdk-common/tests/pyodide/test_browser.py deleted file mode 100644 index c0c6e9c4a..000000000 --- a/packages/evo-sdk-common/tests/pyodide/test_browser.py +++ /dev/null @@ -1,125 +0,0 @@ -# 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 json -import sys -import unittest -from unittest import mock - -from evo.oauth.exceptions import OAuthError - -from ._stubs import FakeFetch, FakeLocalStorage, FakeResponse, expired_jwt, install, valid_jwt - -install() - -from evo.pyodide import _browser, get_browser_access_token, get_browser_config # noqa: E402 - - -class TestIsExpired(unittest.TestCase): - def test_expired_token(self) -> None: - assert _browser._is_expired(expired_jwt()) - - def test_valid_token(self) -> None: - assert not _browser._is_expired(valid_jwt()) - - def test_opaque_token_is_assumed_valid(self) -> None: - assert not _browser._is_expired("an-opaque-token") - - def test_undecodable_payload_is_assumed_valid(self) -> None: - assert not _browser._is_expired("header.!!!not-base64!!!.signature") - - def test_token_without_exp_claim_is_assumed_valid(self) -> None: - assert not _browser._is_expired("header.e30.signature") # Payload is `{}`. - - -class TestGetBrowserAccessToken(unittest.TestCase): - def set_storage(self, items: dict[str, str]) -> None: - patcher = mock.patch.object(sys.modules["js"], "localStorage", FakeLocalStorage(items)) - patcher.start() - self.addCleanup(patcher.stop) - - def test_returns_stored_token(self) -> None: - token = valid_jwt() - self.set_storage({"accessToken": json.dumps({"access_token": token})}) - assert get_browser_access_token() == token - - def test_reads_custom_storage_key(self) -> None: - token = valid_jwt() - self.set_storage({"evoToken": json.dumps({"access_token": token})}) - assert get_browser_access_token(storage_key="evoToken") == token - - def test_missing_key(self) -> None: - self.set_storage({}) - with self.assertRaisesRegex(OAuthError, "No access token found"): - get_browser_access_token() - - def test_empty_value(self) -> None: - self.set_storage({"accessToken": ""}) - with self.assertRaisesRegex(OAuthError, "No access token found"): - get_browser_access_token() - - def test_value_is_not_json(self) -> None: - self.set_storage({"accessToken": "not json"}) - with self.assertRaisesRegex(OAuthError, "not a valid access token document"): - get_browser_access_token() - - def test_document_is_missing_access_token(self) -> None: - self.set_storage({"accessToken": json.dumps({"refresh_token": "abc"})}) - with self.assertRaisesRegex(OAuthError, "not a valid access token document"): - get_browser_access_token() - - def test_access_token_is_not_a_string(self) -> None: - self.set_storage({"accessToken": json.dumps({"access_token": 1234})}) - with self.assertRaisesRegex(OAuthError, "does not contain an access token"): - get_browser_access_token() - - def test_expired_token(self) -> None: - self.set_storage({"accessToken": json.dumps({"access_token": expired_jwt()})}) - with self.assertRaisesRegex(OAuthError, "has expired"): - get_browser_access_token() - - def test_opaque_token_is_accepted(self) -> None: - self.set_storage({"accessToken": json.dumps({"access_token": "an-opaque-token"})}) - assert get_browser_access_token() == "an-opaque-token" - - -class TestGetBrowserConfig(unittest.IsolatedAsyncioTestCase): - def patch_fetch(self, *responses: FakeResponse, error: Exception | None = None) -> FakeFetch: - fetch = FakeFetch(*responses, error=error) - patcher = mock.patch.object(sys.modules["pyodide.http"], "pyfetch", fetch) - patcher.start() - self.addCleanup(patcher.stop) - return fetch - - async def test_returns_parsed_config(self) -> None: - fetch = self.patch_fetch( - FakeResponse(status=200, body=json.dumps({"discovery_url": "https://d.test"}).encode()) - ) - - assert await get_browser_config() == {"discovery_url": "https://d.test"} - assert fetch.calls[0][0] == "/config.json" - - async def test_reads_custom_url(self) -> None: - fetch = self.patch_fetch(FakeResponse(status=200, body=b"{}")) - await get_browser_config("/custom.json") - assert fetch.calls[0][0] == "/custom.json" - - async def test_missing_config_returns_empty_mapping(self) -> None: - self.patch_fetch(FakeResponse(status=404, status_text="Not Found")) - assert await get_browser_config() == {} - - async def test_unparseable_config_returns_empty_mapping(self) -> None: - self.patch_fetch(FakeResponse(status=200, body=b"not json")) - assert await get_browser_config() == {} - - async def test_fetch_failure_returns_empty_mapping(self) -> None: - self.patch_fetch(error=RuntimeError("network down")) - assert await get_browser_config() == {} diff --git a/packages/evo-sdk-common/tests/pyodide/test_transport.py b/packages/evo-sdk-common/tests/pyodide/test_transport.py deleted file mode 100644 index d960a0a3f..000000000 --- a/packages/evo-sdk-common/tests/pyodide/test_transport.py +++ /dev/null @@ -1,335 +0,0 @@ -# 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 asyncio -import json -import unittest -from unittest import mock - -from evo.common import HTTPHeaderDict, RequestMethod -from evo.common.exceptions import ClientValueError, TransportError -from evo.common.interfaces import ITransport -from evo.common.io.http import HTTPSource - -from ._stubs import FakeBlob, FakeFetch, FakeFormData, FakeResponse, install - -install() - -from evo.pyodide import JsTransport # noqa: E402 -from evo.pyodide import transport as transport_module # noqa: E402 - - -class TestJsTransport(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.transport = JsTransport(user_agent="test-agent") - - def patch_fetch(self, *responses: FakeResponse, error: Exception | None = None) -> FakeFetch: - fetch = FakeFetch(*responses, error=error) - patcher = mock.patch.object(transport_module, "pyfetch", fetch) - patcher.start() - self.addCleanup(patcher.stop) - return fetch - - def test_implements_transport_interface(self) -> None: - assert isinstance(self.transport, ITransport) - - def test_default_user_agent(self) -> None: - assert JsTransport().user_agent == "evo-sdk-common" - - async def test_open_and_close_are_noops(self) -> None: - await self.transport.open() - await self.transport.close() - - async def test_async_context_manager(self) -> None: - async with self.transport as entered: - assert entered is self.transport - - async def test_get_request(self) -> None: - fetch = self.patch_fetch(FakeResponse(status=200, body=b"hello", headers={"Content-Type": "text/plain"})) - - response = await self.transport.request(RequestMethod.GET, "https://example.test/thing") - - assert response.status == 200 - assert response.data == b"hello" - assert response.headers["Content-Type"] == "text/plain" - assert fetch.calls[0][0] == "https://example.test/thing" - assert fetch.last_call["method"] == "GET" - - async def test_adds_default_user_agent(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - await self.transport.request(RequestMethod.GET, "https://example.test/") - assert fetch.last_call["headers"]["User-Agent"] == "test-agent" - - async def test_does_not_override_user_agent(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - await self.transport.request( - RequestMethod.GET, "https://example.test/", headers=HTTPHeaderDict({"User-Agent": "caller"}) - ) - assert fetch.last_call["headers"]["User-Agent"] == "caller" - - async def test_body_and_post_params_are_mutually_exclusive(self) -> None: - self.patch_fetch(FakeResponse()) - with self.assertRaises(ClientValueError): - await self.transport.request( - RequestMethod.POST, "https://example.test/", post_params=[("a", "b")], body={"a": "b"} - ) - - async def test_serialises_object_body_as_json(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - - await self.transport.request(RequestMethod.POST, "https://example.test/", body={"name": "value"}) - - assert json.loads(fetch.last_call["body"]) == {"name": "value"} - assert fetch.last_call["headers"]["Content-Type"] == "application/json" - - async def test_preserves_explicit_content_type_for_object_body(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - - await self.transport.request( - RequestMethod.POST, - "https://example.test/", - headers=HTTPHeaderDict({"Content-Type": "application/vnd.custom+json"}), - body={"name": "value"}, - ) - - assert fetch.last_call["headers"]["Content-Type"] == "application/vnd.custom+json" - - async def test_passes_string_body_through_unchanged(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - await self.transport.request(RequestMethod.POST, "https://example.test/", body="raw") - assert fetch.last_call["body"] == "raw" - - async def test_url_encodes_form_post_params(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - - await self.transport.request( - RequestMethod.POST, - "https://example.test/", - headers=HTTPHeaderDict({"Content-Type": "application/x-www-form-urlencoded"}), - post_params=[("one", "1"), ("two", "2")], - ) - - assert fetch.last_call["body"] == "one=1&two=2" - - async def test_builds_form_data_for_multipart_post_params(self) -> None: - fetch = self.patch_fetch(FakeResponse()) - - await self.transport.request( - RequestMethod.POST, - "https://example.test/", - headers=HTTPHeaderDict({"Content-Type": "multipart/form-data"}), - post_params=[("file", b"bytes"), ("name", "value")], - ) - - body = fetch.last_call["body"] - assert isinstance(body, FakeFormData) - assert body.fields[0][0] == "file" - assert isinstance(body.fields[0][1], FakeBlob) - assert body.fields[0][1].parts == [b"bytes"] - assert body.fields[1] == ("name", "value") - assert "Content-Type" not in fetch.last_call["headers"] - - async def test_head_request_uses_server_headers_without_reading_body(self) -> None: - response = FakeResponse(status=200, headers={"Content-Length": "10", "Accept-Ranges": "bytes"}) - fetch = self.patch_fetch(response) - - result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") - - assert fetch.last_call["method"] == "HEAD" - assert result.data == b"" - assert result.headers["Content-Length"] == "10" - assert result.headers["Accept-Ranges"] == "bytes" - assert not response.body_read - - async def test_head_probes_a_byte_if_cors_hides_required_headers(self) -> None: - fetch = self.patch_fetch(FakeResponse(), FakeResponse(status=206, headers={"Content-Range": "bytes 0-0/987"})) - - result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") - - assert [call[1]["method"] for call in fetch.calls] == ["HEAD", "GET"] - assert fetch.last_call["headers"]["Range"] == "bytes=0-0" - assert result.headers["Content-Length"] == "987" - assert result.headers["Accept-Ranges"] == "bytes" - - async def test_head_probe_preserves_partially_exposed_headers(self) -> None: - for headers in ({"Content-Length": "987"}, {"Accept-Ranges": "bytes"}): - with self.subTest(headers=headers): - self.patch_fetch( - FakeResponse(headers=headers), - FakeResponse(status=206, headers={"Content-Range": "bytes 0-0/987"}), - ) - - result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") - - assert result.headers["Content-Length"] == "987" - assert result.headers["Accept-Ranges"] == "bytes" - - async def test_http_source_accepts_size_when_head_hides_range_support(self) -> None: - self.patch_fetch( - FakeResponse(headers={"Content-Length": "987"}), - FakeResponse(status=206, headers={"Content-Range": "bytes 0-0/987"}), - ) - - async with HTTPSource(mock.AsyncMock(return_value="https://example.test/file"), self.transport) as source: - assert await source.get_size() == 987 - - async def test_head_does_not_claim_range_support_without_evidence(self) -> None: - probe = FakeResponse(status=200, body=b"large file") - self.patch_fetch(FakeResponse(), probe) - - result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") - - assert "Accept-Ranges" not in result.headers - assert "Content-Length" not in result.headers - assert not probe.body_read - - async def test_head_request_keeps_headers_reported_by_the_server(self) -> None: - response = FakeResponse(status=200, headers={"Content-Length": "99", "Accept-Ranges": "none"}) - self.patch_fetch(response) - - result = await self.transport.request(RequestMethod.HEAD, "https://example.test/file") - - assert result.headers["Content-Length"] == "99" - assert result.headers["Accept-Ranges"] == "none" - - async def test_redirect_response_has_no_body(self) -> None: - response = FakeResponse(status=302, headers={"Location": "https://example.test/elsewhere"}) - self.patch_fetch(response) - - result = await self.transport.request(RequestMethod.GET, "https://example.test/") - - assert result.status == 302 - assert result.data == b"" - assert result.headers["Location"] == "https://example.test/elsewhere" - assert not response.body_read - - async def test_followed_task_submission_redirect_is_restored_as_303(self) -> None: - for status in (200, 202): - with self.subTest(status=status): - response = FakeResponse( - status=status, url="https://example.test/compute/orgs/org/topic/task/job/status", redirected=True - ) - self.patch_fetch(response) - - result = await self.transport.request( - RequestMethod.POST, "https://example.test/compute/orgs/org/topic/task" - ) - - assert result.status == 303 - assert result.reason == "See Other" - assert result.headers["Location"] == "https://example.test/compute/orgs/org/topic/task/job/status" - assert result.data == b"" - assert not response.body_read - - async def test_unsuccessful_task_submission_redirect_is_not_restored(self) -> None: - self.patch_fetch( - FakeResponse(status=500, url="https://example.test/compute/orgs/org/topic/task/job/status", redirected=True) - ) - - with self.assertRaisesRegex(TransportError, "original response is unavailable"): - await self.transport.request(RequestMethod.POST, "https://example.test/compute/orgs/org/topic/task") - - async def test_unrelated_followed_redirect_fails_instead_of_returning_final_response(self) -> None: - response = FakeResponse(status=200, url="https://example.test/file", redirected=True) - self.patch_fetch(response) - - with self.assertRaisesRegex(TransportError, "original response is unavailable"): - await self.transport.request(RequestMethod.PUT, "https://example.test/files/1") - - async def test_unredirected_202_is_left_alone(self) -> None: - self.patch_fetch(FakeResponse(status=202, body=b"accepted")) - - result = await self.transport.request(RequestMethod.POST, "https://example.test/tasks") - - assert result.status == 202 - assert result.data == b"accepted" - - async def test_synthesises_content_range_when_hidden_by_cors(self) -> None: - self.patch_fetch(FakeResponse(status=206, body=b"0123")) - - result = await self.transport.request( - RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) - ) - - assert result.headers["Content-Range"] == "bytes 0-3/*" - - async def test_keeps_content_range_reported_by_the_server(self) -> None: - self.patch_fetch(FakeResponse(status=206, body=b"0123", headers={"Content-Range": "bytes 0-3/10"})) - - result = await self.transport.request( - RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) - ) - - assert result.headers["Content-Range"] == "bytes 0-3/10" - - async def test_does_not_invent_content_range_if_server_ignores_range(self) -> None: - self.patch_fetch(FakeResponse(status=200, body=b"full file")) - - result = await self.transport.request( - RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) - ) - - assert "Content-Range" not in result.headers - - async def test_does_not_invent_content_range_if_length_mismatches(self) -> None: - self.patch_fetch(FakeResponse(status=206, body=b"012345")) - - result = await self.transport.request( - RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "bytes=0-3"}) - ) - - assert "Content-Range" not in result.headers - - async def test_ignores_malformed_range_header(self) -> None: - self.patch_fetch(FakeResponse(status=206, body=b"0123")) - - result = await self.transport.request( - RequestMethod.GET, "https://example.test/file", headers=HTTPHeaderDict({"Range": "nonsense"}) - ) - - assert "Content-Range" not in result.headers - - async def test_wraps_fetch_failures_in_transport_error(self) -> None: - self.patch_fetch(error=RuntimeError("network down")) - - with self.assertRaises(TransportError) as ctx: - await self.transport.request(RequestMethod.GET, "https://example.test/") - - assert isinstance(ctx.exception.caused_by, RuntimeError) - - async def test_connection_timeout_aborts_fetch(self) -> None: - signals = [] - - async def stalled_fetch(url: str, **options: object) -> FakeResponse: - signals.append(options["signal"]) - await asyncio.Event().wait() - raise AssertionError("unreachable") - - with mock.patch.object(transport_module, "pyfetch", stalled_fetch): - with self.assertRaises(TransportError) as ctx: - await self.transport.request(RequestMethod.GET, "https://example.test/", request_timeout=(0.01, 1)) - - assert isinstance(ctx.exception.caused_by, asyncio.TimeoutError) - assert signals[0].aborted - - async def test_total_timeout_aborts_body_read(self) -> None: - class StalledResponse(FakeResponse): - async def bytes(self) -> bytes: - await asyncio.Event().wait() - raise AssertionError("unreachable") - - fetch = self.patch_fetch(StalledResponse()) - - with self.assertRaises(TransportError) as ctx: - await self.transport.request(RequestMethod.GET, "https://example.test/", request_timeout=0.01) - - assert isinstance(ctx.exception.caused_by, asyncio.TimeoutError) - assert fetch.last_call["signal"].aborted diff --git a/uv.lock b/uv.lock index a5f3a7b51..7d3721ba6 100644 --- a/uv.lock +++ b/uv.lock @@ -1359,13 +1359,6 @@ notebooks = [ { name = "pillow" }, { name = "python-dotenv" }, ] -pyodide = [ - { 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 = "python-dotenv" }, -] [package.dev-dependencies] dev = [ @@ -1393,21 +1386,17 @@ test = [ requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'" }, { name = "anywidget", marker = "extra == 'notebooks'" }, - { name = "anywidget", marker = "extra == 'pyodide'" }, { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, { name = "ipykernel", marker = "extra == 'notebooks'" }, - { name = "ipykernel", marker = "extra == 'pyodide'" }, { name = "ipython", marker = "extra == 'notebooks'" }, - { name = "ipython", marker = "extra == 'pyodide'" }, { name = "jmespath", marker = "extra == 'jmespath'", specifier = ">=1.0.1" }, { name = "pillow", marker = "extra == 'notebooks'" }, { name = "pure-interface", specifier = ">=8.0.0" }, { name = "pydantic", specifier = ">=2" }, { name = "python-dateutil" }, { name = "python-dotenv", marker = "extra == 'notebooks'" }, - { name = "python-dotenv", marker = "extra == 'pyodide'" }, ] -provides-extras = ["aiohttp", "notebooks", "pyodide", "jmespath"] +provides-extras = ["aiohttp", "notebooks", "jmespath"] [package.metadata.requires-dev] dev = [ From 468b046e8f1afb69e3aefe20ca842ec1a5bc2fdb Mon Sep 17 00:00:00 2001 From: Eric Daniels <106169966+geostats-BSY@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:13:44 -0700 Subject: [PATCH 8/8] remove lingering token related items --- .../evo-sdk-common/src/evo/notebooks/widgets.py | 16 ---------------- .../notebooks/test_service_manager_widget.py | 12 ------------ 2 files changed, 28 deletions(-) diff --git a/packages/evo-sdk-common/src/evo/notebooks/widgets.py b/packages/evo-sdk-common/src/evo/notebooks/widgets.py index 06de52306..fff61f208 100644 --- a/packages/evo-sdk-common/src/evo/notebooks/widgets.py +++ b/packages/evo-sdk-common/src/evo/notebooks/widgets.py @@ -29,7 +29,6 @@ from evo.common.interfaces import IAuthorizer, ICache, IContext, IFeedback, ITransport from evo.discovery import Hub, Organization from evo.oauth import AnyScopes, EvoScopes, OAuthConnector -from evo.oauth.exceptions import OAuthError from evo.service_manager import ServiceManager from evo.workspaces import Workspace @@ -236,7 +235,6 @@ def __init__( :param discovery_url: The URL of the Evo Discovery service. :param cache: The cache to use for storing tokens and other data. """ - self._widget_closed = False self._authorizer = authorizer self._cache = cache self._service_manager = ServiceManager( @@ -259,15 +257,6 @@ def __init__( display(self, self._org_selector, self._workspace_selector) - def close(self) -> None: - """Close the widget and prevent further use.""" - self._widget_closed = True - super().close() - - def _check_not_closed(self) -> None: - if self._widget_closed: - raise OAuthError("This ServiceManagerWidget is closed. Create a new widget to continue.") - @classmethod def with_auth_code( cls, @@ -343,9 +332,7 @@ async def login(self, timeout_seconds: int = 180) -> ServiceManagerWidget: :param timeout_seconds: The maximum time (in seconds) to wait for the authorisation process to complete. :returns: The current instance of the ServiceManagerWidget. - :raises OAuthError: If the widget is closed. """ - self._check_not_closed() # Open the transport without closing it to avoid the overhead of opening it multiple times. await self._service_manager._transport.open() with self._loading(): @@ -370,8 +357,6 @@ def _update_btn(self, signed_in: bool) -> None: self.button_text = "Sign In" def _handle_frontend_msg(self, _widget: object, content: object, _buffers: list) -> asyncio.Future | None: - if self._widget_closed: - return None if isinstance(content, dict) and content.get("type") == "click": return asyncio.ensure_future(self.refresh_services()) return None @@ -396,7 +381,6 @@ def _loading_services(self) -> Iterator[None]: self._org_selector.refresh() async def refresh_services(self) -> None: - self._check_not_closed() with self._loading(): with self._loading_services(): try: 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 index 79db4aa2f..38672bfc8 100644 --- a/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py +++ b/packages/evo-sdk-common/tests/notebooks/test_service_manager_widget.py @@ -17,7 +17,6 @@ from evo.common.interfaces import IContext from evo.notebooks import ServiceManagerWidget -from evo.oauth.exceptions import OAuthError class TestServiceManagerWidget(unittest.IsolatedAsyncioTestCase): @@ -47,14 +46,3 @@ async def test_frontend_click_refreshes_services(self) -> None: await task refresh.assert_awaited_once() - - async def test_close_prevents_reuse(self) -> None: - self.widget.close() - self.widget.close() - - assert self.widget.comm is None - for operation in (self.widget.login, self.widget.refresh_services): - with self.subTest(operation=operation.__name__): - with self.assertRaisesRegex(OAuthError, "closed"): - await operation() - assert self.widget._handle_frontend_msg(self.widget, {"type": "click"}, []) is None