From 9cb98dcdb0371490d7e4715e0ba84e76cb1f45be Mon Sep 17 00:00:00 2001
From: Amirabbas Jalali
<258926044+amirabbas-jalali-bentley@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:29:18 -0600
Subject: [PATCH 1/4] Add a discovery client for compute tasks
DiscoveryClient lists the compute tasks available to an organization,
with each task's parameter and result schemas, through
GET /compute/orgs/{org_id}/tasks. It follows the paginated listing to
the end and caches the catalogue for a configurable time. get_topics,
get_topic_tasks and get_task read from the same cache, and every
accessor takes force_refresh.
DiscoveryApi wraps the endpoint by hand, in the same style as the
generated JobsApi and TasksApi.
---
mkdocs/api_clients.txt | 1 +
.../evo-compute/src/evo/compute/__init__.py | 4 +
.../evo-compute/src/evo/compute/discovery.py | 165 +++++++++++++
.../src/evo/compute/endpoints/api/__init__.py | 1 +
.../compute/endpoints/api/discovery_api.py | 119 +++++++++
.../src/evo/compute/endpoints/models.py | 62 +++++
.../tests/data/discovery-tasks.json | 79 ++++++
packages/evo-compute/tests/test_discovery.py | 232 ++++++++++++++++++
8 files changed, 663 insertions(+)
create mode 100644 packages/evo-compute/src/evo/compute/discovery.py
create mode 100644 packages/evo-compute/src/evo/compute/endpoints/api/discovery_api.py
create mode 100644 packages/evo-compute/tests/data/discovery-tasks.json
create mode 100644 packages/evo-compute/tests/test_discovery.py
diff --git a/mkdocs/api_clients.txt b/mkdocs/api_clients.txt
index 93ea5f468..7b5a9a9db 100644
--- a/mkdocs/api_clients.txt
+++ b/mkdocs/api_clients.txt
@@ -1,6 +1,7 @@
packages.evo-blockmodels.src.evo.blockmodels.client.BlockModelAPIClient
packages.evo-colormaps.src.evo.colormaps.client.ColormapAPIClient
packages.evo-compute.src.evo.compute.client.JobClient
+packages.evo-compute.src.evo.compute.discovery.DiscoveryClient
packages.evo-sdk-common.src.evo.discovery.client.DiscoveryAPIClient
packages.evo-files.src.evo.files.client.FileAPIClient
packages.evo-objects.src.evo.objects.client.api_client.ObjectAPIClient
diff --git a/packages/evo-compute/src/evo/compute/__init__.py b/packages/evo-compute/src/evo/compute/__init__.py
index 2ceae317e..be2b5bb13 100644
--- a/packages/evo-compute/src/evo/compute/__init__.py
+++ b/packages/evo-compute/src/evo/compute/__init__.py
@@ -12,10 +12,14 @@
from . import tasks
from .client import JobClient
from .data import JobProgress, JobStatusEnum
+from .discovery import DiscoveryClient
+from .endpoints.models import TaskResource
__all__ = [
+ "DiscoveryClient",
"JobClient",
"JobProgress",
"JobStatusEnum",
+ "TaskResource",
"tasks",
]
diff --git a/packages/evo-compute/src/evo/compute/discovery.py b/packages/evo-compute/src/evo/compute/discovery.py
new file mode 100644
index 000000000..e950a8840
--- /dev/null
+++ b/packages/evo-compute/src/evo/compute/discovery.py
@@ -0,0 +1,165 @@
+# 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.
+
+"""Discovery client for the compute task catalogue.
+
+``DiscoveryClient`` lists the tasks available to an organization, with the full parameter
+and result schemas for each. The catalogue is paginated by the service; this client fetches
+every page and caches the combined result with a time-to-live. The convenience accessors
+(:meth:`get_topics`, :meth:`get_topic_tasks`, :meth:`get_task`) read from that cache.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from uuid import UUID
+
+from evo.common import APIConnector, IContext
+
+from .endpoints.api import DiscoveryApi
+from .endpoints.models import TaskResource
+
+__all__ = [
+ "DiscoveryClient",
+]
+
+DEFAULT_CACHE_TTL_SECONDS = 300.0
+"""Default time-to-live for the cached task catalogue, in seconds."""
+
+_PAGE_SIZE = 100
+"""Number of tasks requested per page when fetching the catalogue."""
+
+
+class DiscoveryClient:
+ """List the compute task catalogue for an organization.
+
+ :param connector: The API connector to use.
+ :param org_id: The organization ID.
+ :param cache_ttl_seconds: How long a fetched catalogue is cached before the next call
+ re-fetches it. Pass ``force_refresh=True`` to any accessor to re-fetch sooner.
+ """
+
+ def __init__(
+ self,
+ connector: APIConnector,
+ org_id: UUID,
+ *,
+ cache_ttl_seconds: float = DEFAULT_CACHE_TTL_SECONDS,
+ ) -> None:
+ self._connector = connector
+ self._org_id = org_id
+ self._cache_ttl_seconds = cache_ttl_seconds
+ self._discovery_api = DiscoveryApi(connector)
+ self._mutex = asyncio.Lock()
+ self._cache: list[TaskResource] = []
+ self._cache_expiry: float | None = None
+
+ @classmethod
+ def from_context(
+ cls,
+ context: IContext,
+ *,
+ cache_ttl_seconds: float = DEFAULT_CACHE_TTL_SECONDS,
+ ) -> DiscoveryClient:
+ """Create a DiscoveryClient from any real ``evo.common.IContext``.
+
+ :param context: The context to create the client from (provides the connector and org id).
+ :param cache_ttl_seconds: The catalogue cache time-to-live, in seconds.
+
+ :return: A DiscoveryClient instance.
+ """
+ return cls(
+ context.get_connector(),
+ context.get_org_id(),
+ cache_ttl_seconds=cache_ttl_seconds,
+ )
+
+ @property
+ def org_id(self) -> UUID:
+ """The organization ID."""
+ return self._org_id
+
+ def __repr__(self) -> str:
+ return f"DiscoveryClient(org_id={self._org_id!r})"
+
+ def _cache_is_valid(self) -> bool:
+ return self._cache_expiry is not None and time.monotonic() < self._cache_expiry
+
+ async def _fetch(self) -> None:
+ """Fetch every page of the catalogue and refresh the cache."""
+ tasks: list[TaskResource] = []
+ offset = 0
+ async with self._connector:
+ while True:
+ page = await self._discovery_api.list_tasks(
+ org_id=str(self._org_id),
+ details=True,
+ limit=_PAGE_SIZE,
+ offset=offset,
+ )
+ tasks.extend(page.results)
+ offset += len(page.results)
+ # Stop on an empty page, an unreported total, or once the reported total is reached.
+ if not page.results or page.total is None or offset >= page.total:
+ break
+ self._cache = tasks
+ self._cache_expiry = time.monotonic() + self._cache_ttl_seconds
+
+ async def _catalogue(self, force_refresh: bool) -> list[TaskResource]:
+ """Return the cached catalogue, fetching it first if it is stale or forced."""
+ async with self._mutex:
+ if force_refresh or not self._cache_is_valid():
+ await self._fetch()
+ return self._cache
+
+ async def list_tasks(self, *, force_refresh: bool = False) -> list[TaskResource]:
+ """Return the full catalogue of discoverable tasks.
+
+ Served from the cache while it is within its TTL; pass ``force_refresh=True`` to
+ re-fetch immediately.
+
+ :param force_refresh: Re-fetch the catalogue even if the cache is still valid.
+
+ :return: Every advertised task.
+ """
+ return list(await self._catalogue(force_refresh))
+
+ async def get_topics(self, *, force_refresh: bool = False) -> list[str]:
+ """Return the sorted list of topics that have discoverable tasks.
+
+ :param force_refresh: Re-fetch the catalogue even if the cache is still valid.
+ """
+ return sorted({task.topic for task in await self._catalogue(force_refresh)})
+
+ async def get_topic_tasks(self, topic: str, *, force_refresh: bool = False) -> list[TaskResource]:
+ """Return the tasks advertised within a single topic.
+
+ :param topic: The task topic.
+ :param force_refresh: Re-fetch the catalogue even if the cache is still valid.
+
+ :return: The tasks in the topic (empty if the topic is not advertised).
+ """
+ return [task for task in await self._catalogue(force_refresh) if task.topic == topic]
+
+ async def get_task(self, topic: str, name: str, *, force_refresh: bool = False) -> TaskResource | None:
+ """Return a single task by topic and name, or ``None`` if it is not advertised.
+
+ :param topic: The task topic.
+ :param name: The task name as advertised by the platform (e.g. ``kriging``).
+ :param force_refresh: Re-fetch the catalogue even if the cache is still valid.
+
+ :return: The matching task, or ``None``.
+ """
+ for task in await self._catalogue(force_refresh):
+ if task.topic == topic and task.name == name:
+ return task
+ return None
diff --git a/packages/evo-compute/src/evo/compute/endpoints/api/__init__.py b/packages/evo-compute/src/evo/compute/endpoints/api/__init__.py
index c2eec5074..55ce11c3d 100644
--- a/packages/evo-compute/src/evo/compute/endpoints/api/__init__.py
+++ b/packages/evo-compute/src/evo/compute/endpoints/api/__init__.py
@@ -9,5 +9,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from .discovery_api import DiscoveryApi # noqa: F401
from .jobs_api import JobsApi # noqa: F401
from .tasks_api import TasksApi # noqa: F401
diff --git a/packages/evo-compute/src/evo/compute/endpoints/api/discovery_api.py b/packages/evo-compute/src/evo/compute/endpoints/api/discovery_api.py
new file mode 100644
index 000000000..1ccc7f9dd
--- /dev/null
+++ b/packages/evo-compute/src/evo/compute/endpoints/api/discovery_api.py
@@ -0,0 +1,119 @@
+# 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.
+"""Endpoint wrapper for listing an organization's compute tasks.
+
+Wraps ``GET /compute/orgs/{org_id}/tasks``, returning the task catalogue with optional
+pagination, filtering, and (with ``details=true``) the full parameter and result schemas
+for each task.
+"""
+
+from typing import Any
+
+from evo.common.connector import APIConnector
+from evo.common.data import RequestMethod
+from evo.common.utils import get_header_metadata
+
+from ..models import DiscoveryResponse
+
+__all__ = ["DiscoveryApi"]
+
+
+class DiscoveryApi:
+ """API client for the compute task discovery endpoint.
+
+ :param connector: Client for communicating with the API.
+ """
+
+ def __init__(self, connector: APIConnector):
+ self.connector = connector
+
+ async def list_tasks(
+ self,
+ org_id: str,
+ details: bool = False,
+ limit: int | None = None,
+ offset: int | None = None,
+ name: str | None = None,
+ topic: str | None = None,
+ key: str | None = None,
+ additional_headers: dict[str, str] | None = None,
+ request_timeout: int | float | tuple[int | float, int | float] | None = None,
+ ) -> DiscoveryResponse:
+ """List the compute tasks discoverable by an organization.
+
+ Wraps ``GET /compute/orgs/{org_id}/tasks``. The endpoint is paginated; use ``limit``
+ and ``offset`` to page through the results.
+
+ :param org_id: The organization identifier.
+ Format: `uuid`
+ :param details: When ``True``, request the full parameter/result schemas for each
+ task (``details=true``) rather than a summary listing.
+ :param limit: Maximum number of tasks to return in the page.
+ :param offset: Index of the first task to return.
+ :param name: Only return tasks with this name.
+ :param topic: Only return tasks within this topic.
+ :param key: Only return the task with this key.
+ :param additional_headers: (optional) Additional headers to send with the request.
+ :param request_timeout: (optional) Timeout setting for this request. If one number is
+ provided, it will be the total request timeout. It can also be a pair (tuple) of
+ (connection, read) timeouts.
+
+ :return: A page of the discovery response containing the advertised tasks.
+
+ :raise evo.common.exceptions.BadRequestException: If the server responds with HTTP status 400.
+ :raise evo.common.exceptions.UnauthorizedException: If the server responds with HTTP status 401.
+ :raise evo.common.exceptions.ForbiddenException: If the server responds with HTTP status 403.
+ :raise evo.common.exceptions.NotFoundException: If the server responds with HTTP status 404.
+ :raise evo.common.exceptions.BaseTypedError: If the server responds with any other HTTP status between
+ 400 and 599, and the body of the response contains a descriptive `type` parameter.
+ :raise evo.common.exceptions.EvoAPIException: If the server responds with any other HTTP status between 400
+ and 599, and the body of the response does not contain a `type` parameter.
+ :raise evo.common.exceptions.UnknownResponseError: For other HTTP status codes with no corresponding response
+ type in `response_types_map`.
+ """
+ _path_params = {
+ "org_id": org_id,
+ }
+
+ # Serialize the bool as lowercase "true"/"false"; urlencode would emit Python's "True".
+ _query_params: dict[str, Any] = {
+ "details": str(details).lower(),
+ }
+ if limit is not None:
+ _query_params["limit"] = limit
+ if offset is not None:
+ _query_params["offset"] = offset
+ if name is not None:
+ _query_params["name"] = name
+ if topic is not None:
+ _query_params["topic"] = topic
+ if key is not None:
+ _query_params["key"] = key
+
+ _header_params = {
+ "Accept": "application/json",
+ } | get_header_metadata(__name__)
+ if additional_headers is not None:
+ _header_params.update(additional_headers)
+
+ _response_types_map = {
+ "200": DiscoveryResponse,
+ }
+
+ return await self.connector.call_api(
+ method=RequestMethod.GET,
+ resource_path="/compute/orgs/{org_id}/tasks",
+ path_params=_path_params,
+ query_params=_query_params,
+ header_params=_header_params,
+ response_types_map=_response_types_map,
+ request_timeout=request_timeout,
+ )
diff --git a/packages/evo-compute/src/evo/compute/endpoints/models.py b/packages/evo-compute/src/evo/compute/endpoints/models.py
index 87b32edd7..d03a955c3 100755
--- a/packages/evo-compute/src/evo/compute/endpoints/models.py
+++ b/packages/evo-compute/src/evo/compute/endpoints/models.py
@@ -12,6 +12,7 @@
from __future__ import annotations
from enum import Enum
+from typing import Any
from pydantic import AnyUrl, BaseModel, ConfigDict, Field, StrictInt, StrictStr
@@ -106,3 +107,64 @@ class CompletedJobResponse(BaseModel):
error: Error | None = None
"""An error that occurred during the job."""
+
+
+# Discovery endpoint models.
+
+
+class TaskResource(BaseModel):
+ """A single compute task advertised by the discovery endpoint."""
+
+ model_config = ConfigDict(
+ extra="allow",
+ )
+
+ topic: StrictStr
+ """The topic the task belongs to (e.g. ``geostatistics``)."""
+
+ name: StrictStr
+ """The task name within the topic (e.g. ``kriging``)."""
+
+ key: StrictStr | None = None
+ """A stable identifier for the task, if provided."""
+
+ display_name: StrictStr | None = None
+ """A human-friendly name for the task."""
+
+ version: StrictStr | None = None
+ """The task version."""
+
+ feature_flag: StrictStr | None = None
+ """The api-preview feature flag gating the task, when it is a preview task."""
+
+ description: StrictStr | None = None
+ """A description of the task."""
+
+ parameters: dict[str, Any] = Field(default_factory=dict)
+ """The JSON Schema for the task's parameters (full schema when fetched with ``details=true``)."""
+
+ results: dict[str, Any] | None = None
+ """The JSON Schema for the task's results, when advertised."""
+
+
+class DiscoveryResponse(BaseModel):
+ """The paginated response from ``GET /compute/orgs/{org_id}/tasks``."""
+
+ model_config = ConfigDict(
+ extra="allow",
+ )
+
+ results: list[TaskResource] = Field(default_factory=list)
+ """The advertised tasks."""
+
+ limit: StrictInt | None = None
+ """The requested page size."""
+
+ offset: StrictInt | None = None
+ """The page offset."""
+
+ total: StrictInt | None = None
+ """The total number of tasks available."""
+
+ count: StrictInt | None = None
+ """The number of tasks returned in this page."""
diff --git a/packages/evo-compute/tests/data/discovery-tasks.json b/packages/evo-compute/tests/data/discovery-tasks.json
new file mode 100644
index 000000000..75adfbf4c
--- /dev/null
+++ b/packages/evo-compute/tests/data/discovery-tasks.json
@@ -0,0 +1,79 @@
+{
+ "limit": 100,
+ "offset": 0,
+ "total": 4,
+ "count": 4,
+ "results": [
+ {
+ "key": "geostatistics/declustering",
+ "topic": "geostatistics",
+ "name": "declustering",
+ "display_name": "Declustering",
+ "version": "0.3.1",
+ "description": "Compute declustering weights for a pointset.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "source": {"type": "object", "reference_to": "geoscience-object"},
+ "power": {"type": ["number", "null"], "default": null}
+ },
+ "required": ["source"]
+ },
+ "results": {
+ "type": "object",
+ "properties": {
+ "target": {"type": "object", "output": "geoscience-object"}
+ }
+ }
+ },
+ {
+ "key": "geostatistics/normal-score",
+ "topic": "geostatistics",
+ "name": "normal-score",
+ "display_name": "Normal Score",
+ "version": "0.2.0",
+ "description": "Transform values to a standard normal distribution.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "distribution": {"type": "object", "reference_to": "geoscience-object"}
+ },
+ "required": ["distribution"]
+ }
+ },
+ {
+ "key": "geostatistics/kriging",
+ "topic": "geostatistics",
+ "name": "kriging",
+ "display_name": "Kriging",
+ "version": "0.5.3",
+ "feature_flag": "geostatistics-preview",
+ "description": "Estimate a target attribute on a grid using kriging.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "source": {"type": "object", "reference_to": "geoscience-object"},
+ "target": {"type": "object", "reference_to": "geoscience-object"},
+ "variogram": {"type": "object", "reference_to": "geoscience-object"}
+ },
+ "required": ["source", "target", "variogram"]
+ }
+ },
+ {
+ "key": "converter/obj-import",
+ "topic": "converter",
+ "name": "obj-import",
+ "display_name": "OBJ Import",
+ "version": "0.1.13",
+ "feature_flag": "converter-preview",
+ "description": "Import a Wavefront OBJ file as a triangular mesh.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "file": {"type": "string", "reference_to": "file"}
+ },
+ "required": ["file"]
+ }
+ }
+ ]
+}
diff --git a/packages/evo-compute/tests/test_discovery.py b/packages/evo-compute/tests/test_discovery.py
new file mode 100644
index 000000000..a7d35fba8
--- /dev/null
+++ b/packages/evo-compute/tests/test_discovery.py
@@ -0,0 +1,232 @@
+# 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 asyncio
+import json
+from collections.abc import Iterator
+from contextlib import contextmanager
+from unittest import mock
+
+from evo.common import RequestMethod
+from evo.common.test_tools import ORG as TEST_ORG
+from evo.common.test_tools import MockResponse, TestWithConnector
+from evo.common.utils import get_header_metadata
+
+from data import load_test_data
+from evo.compute import DiscoveryClient, TaskResource
+from evo.compute.discovery import DEFAULT_CACHE_TTL_SECONDS
+from evo.compute.endpoints.api import DiscoveryApi
+from evo.compute.endpoints.models import DiscoveryResponse
+
+
+class FakeClock:
+ """A controllable monotonic clock, so the cache TTL can be exercised without sleeping."""
+
+ def __init__(self, start: float = 1000.0) -> None:
+ self.now = start
+
+ def __call__(self) -> float:
+ return self.now
+
+ def advance(self, seconds: float) -> None:
+ self.now += seconds
+
+
+class FakeContext:
+ """Minimal duck-typed IContext exposing just what ``from_context`` uses."""
+
+ def __init__(self, connector, org_id) -> None:
+ self._connector = connector
+ self._org_id = org_id
+
+ def get_connector(self):
+ return self._connector
+
+ def get_org_id(self):
+ return self._org_id
+
+
+class TestDiscoveryClient(TestWithConnector):
+ def setUp(self) -> None:
+ super().setUp()
+ self.clock = FakeClock()
+ clock_patch = mock.patch("evo.compute.discovery.time", mock.Mock(monotonic=self.clock))
+ clock_patch.start()
+ self.addCleanup(clock_patch.stop)
+ self.client = DiscoveryClient(connector=self.connector, org_id=TEST_ORG.id)
+ self.setup_universal_headers(get_header_metadata(DiscoveryApi.__module__))
+ self.catalogue = load_test_data("discovery-tasks.json")
+
+ @property
+ def tasks_path(self) -> str:
+ return f"/compute/orgs/{TEST_ORG.id}/tasks?details=true&limit=100&offset=0"
+
+ @contextmanager
+ def set_discovery_response(self, data: dict | None = None) -> Iterator[None]:
+ content = json.dumps(self.catalogue if data is None else data)
+ with self.transport.set_http_response(
+ status_code=200, content=content, headers={"Content-Type": "application/json"}
+ ):
+ yield
+
+ def _page_response(self, page: dict) -> MockResponse:
+ return MockResponse(status_code=200, content=json.dumps(page), headers={"Content-Type": "application/json"})
+
+ # -- request shape ----------------------------------------------------- #
+
+ async def test_list_tasks_requests_details_and_pagination(self) -> None:
+ """The catalogue is fetched with ``details=true`` and pagination params."""
+ with self.set_discovery_response():
+ await self.client.list_tasks()
+ self.assert_request_made(
+ RequestMethod.GET,
+ self.tasks_path,
+ headers={"Accept": "application/json"},
+ )
+
+ async def test_list_tasks_parses_task_resource(self) -> None:
+ """Results deserialize to ``TaskResource`` with the full parameter schema."""
+ with self.set_discovery_response():
+ tasks = await self.client.list_tasks()
+ declustering = next(task for task in tasks if task.name == "declustering")
+ self.assertIsInstance(declustering, TaskResource)
+ self.assertEqual("geostatistics", declustering.topic)
+ self.assertEqual("0.3.1", declustering.version)
+ self.assertIn("source", declustering.parameters.get("properties", {}))
+
+ async def test_lists_every_task(self) -> None:
+ """The whole catalogue is returned, including feature_flag tasks."""
+ with self.set_discovery_response():
+ tasks = await self.client.list_tasks()
+ names = {task.name for task in tasks}
+ self.assertEqual({"declustering", "normal-score", "kriging", "obj-import"}, names)
+
+ # -- pagination -------------------------------------------------------- #
+
+ async def test_fetches_all_pages(self) -> None:
+ """``_fetch`` follows pagination until the reported total is reached."""
+ results = self.catalogue["results"]
+ self.transport.request.side_effect = [
+ self._page_response({"limit": 2, "offset": 0, "total": 3, "count": 2, "results": results[:2]}),
+ self._page_response({"limit": 2, "offset": 2, "total": 3, "count": 1, "results": results[2:3]}),
+ ]
+ tasks = await self.client.list_tasks()
+ self.transport.assert_n_requests_made(2)
+ self.assertEqual(3, len(tasks))
+
+ async def test_stops_when_total_is_missing(self) -> None:
+ """A page without a ``total`` stops pagination instead of looping forever."""
+ results = self.catalogue["results"]
+ self.transport.request.side_effect = [
+ self._page_response({"limit": 2, "offset": 0, "total": None, "count": 2, "results": results[:2]}),
+ ]
+ tasks = await self.client.list_tasks()
+ self.transport.assert_n_requests_made(1)
+ self.assertEqual(2, len(tasks))
+
+ # -- caching / TTL ----------------------------------------------------- #
+
+ async def test_second_call_within_ttl_is_cached(self) -> None:
+ """A second call within the TTL is served from the cache (no new request)."""
+ with self.set_discovery_response():
+ await self.client.list_tasks()
+ self.transport.assert_n_requests_made(1)
+ self.clock.advance(DEFAULT_CACHE_TTL_SECONDS - 1)
+ await self.client.list_tasks()
+ self.transport.assert_n_requests_made(1)
+
+ async def test_cache_expiry_triggers_refetch(self) -> None:
+ """Once the TTL elapses, the catalogue is re-fetched."""
+ with self.set_discovery_response():
+ await self.client.list_tasks()
+ self.clock.advance(DEFAULT_CACHE_TTL_SECONDS + 1)
+ await self.client.list_tasks()
+ self.transport.assert_n_requests_made(2)
+
+ async def test_force_refresh_bypasses_cache(self) -> None:
+ """``list_tasks(force_refresh=True)`` always re-fetches."""
+ with self.set_discovery_response():
+ await self.client.list_tasks()
+ await self.client.list_tasks(force_refresh=True)
+ self.transport.assert_n_requests_made(2)
+
+ async def test_accessor_force_refresh_bypasses_cache(self) -> None:
+ """``force_refresh`` is wired through the convenience accessors."""
+ with self.set_discovery_response():
+ await self.client.get_topics()
+ await self.client.get_topics(force_refresh=True)
+ self.transport.assert_n_requests_made(2)
+
+ async def test_returned_list_is_a_copy(self) -> None:
+ """Mutating the returned list must not corrupt the internal cache."""
+ with self.set_discovery_response():
+ first = await self.client.list_tasks()
+ first.clear()
+ second = await self.client.list_tasks()
+ self.assertTrue(second)
+
+ async def test_concurrent_cold_calls_fetch_once(self) -> None:
+ """A burst of concurrent calls on a cold cache triggers a single fetch."""
+ calls = 0
+ first_call_started = asyncio.Event()
+ release = asyncio.Event()
+
+ async def slow_list_tasks(**kwargs: object) -> DiscoveryResponse:
+ nonlocal calls
+ calls += 1
+ first_call_started.set()
+ await release.wait()
+ return DiscoveryResponse.model_validate(self.catalogue)
+
+ with mock.patch.object(self.client._discovery_api, "list_tasks", new=slow_list_tasks):
+ gathered = asyncio.gather(
+ self.client.list_tasks(),
+ self.client.list_tasks(),
+ self.client.list_tasks(),
+ )
+ await first_call_started.wait()
+ release.set()
+ results = await gathered
+
+ self.assertEqual(1, calls)
+ self.assertTrue(all(results))
+
+ # -- convenience helpers ----------------------------------------------- #
+
+ async def test_get_topics(self) -> None:
+ with self.set_discovery_response():
+ topics = await self.client.get_topics()
+ self.assertEqual(["converter", "geostatistics"], topics)
+
+ async def test_get_topic_tasks(self) -> None:
+ with self.set_discovery_response():
+ tasks = await self.client.get_topic_tasks("geostatistics")
+ self.assertEqual({"declustering", "normal-score", "kriging"}, {task.name for task in tasks})
+
+ async def test_get_task_found_and_missing(self) -> None:
+ with self.set_discovery_response():
+ found = await self.client.get_task("geostatistics", "declustering")
+ missing = await self.client.get_task("geostatistics", "does-not-exist")
+ self.assertIsNotNone(found)
+ assert found is not None # for type-checkers
+ self.assertEqual("declustering", found.name)
+ self.assertIsNone(missing)
+
+ # -- construction ------------------------------------------------------ #
+
+ async def test_from_context(self) -> None:
+ client = DiscoveryClient.from_context(FakeContext(self.connector, TEST_ORG.id))
+ self.assertEqual(TEST_ORG.id, client.org_id)
+ with self.set_discovery_response():
+ tasks = await client.list_tasks()
+ self.assertTrue(tasks)
From f7b533ee59babcbed80b2a464cee2678b71483f7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:29:55 +0000
Subject: [PATCH 2/4] Update to MkDocs documentation
---
mkdocs/site/packages/evo-compute/JobClient.html | 2 +-
mkdocs/site/packages/evo-compute/index.html | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/mkdocs/site/packages/evo-compute/JobClient.html b/mkdocs/site/packages/evo-compute/JobClient.html
index dbe7b98fc..3c88fb2f4 100644
--- a/mkdocs/site/packages/evo-compute/JobClient.html
+++ b/mkdocs/site/packages/evo-compute/JobClient.html
@@ -34,7 +34,7 @@