diff --git a/sdks/sandbox/python/src/opensandbox/sync/__init__.py b/sdks/sandbox/python/src/opensandbox/sync/__init__.py index 7ce54938..277767df 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/sync/__init__.py @@ -18,6 +18,7 @@ """ from opensandbox.sync.manager import SandboxManagerSync +from opensandbox.sync.pool_manager import PoolManagerSync from opensandbox.sync.sandbox import SandboxSync -__all__ = ["SandboxSync", "SandboxManagerSync"] +__all__ = ["SandboxSync", "SandboxManagerSync", "PoolManagerSync"] diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py index 4f787767..ac13f8ff 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py @@ -23,12 +23,14 @@ from opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync from opensandbox.sync.adapters.health_adapter import HealthAdapterSync from opensandbox.sync.adapters.metrics_adapter import MetricsAdapterSync +from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync from opensandbox.sync.adapters.sandboxes_adapter import SandboxesAdapterSync from opensandbox.sync.services import ( CommandsSync, FilesystemSync, HealthSync, MetricsSync, + PoolsSync, SandboxesSync, ) @@ -40,6 +42,9 @@ def __init__(self, connection_config: ConnectionConfigSync) -> None: def create_sandbox_service(self) -> SandboxesSync: return SandboxesAdapterSync(self.connection_config) + def create_pool_service(self) -> PoolsSync: + return PoolsAdapterSync(self.connection_config) + def create_filesystem_service(self, endpoint: SandboxEndpoint) -> FilesystemSync: return FilesystemAdapterSync(self.connection_config, endpoint) diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/pools_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/pools_adapter.py new file mode 100644 index 00000000..d3be8e3b --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/pools_adapter.py @@ -0,0 +1,209 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# 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. +# +""" +Synchronous pool service adapter. + +Implements the PoolsSync Protocol by calling the lifecycle API over HTTP +using synchronous (httpx sync) transports, following the same patterns as +SandboxesAdapterSync. +""" + +import logging + +import httpx + +from opensandbox.adapters.converter.exception_converter import ExceptionConverter +from opensandbox.adapters.converter.response_handler import handle_api_error, require_parsed +from opensandbox.api.lifecycle import AuthenticatedClient +from opensandbox.api.lifecycle.models.pool_capacity_spec import ApiPoolCapacitySpec +from opensandbox.api.lifecycle.models.create_pool_request import ApiCreatePoolRequest +from opensandbox.api.lifecycle.models.update_pool_request import ApiUpdatePoolRequest +from opensandbox.api.lifecycle.models.pool_response import ApiPoolResponse +from opensandbox.api.lifecycle.models.list_pools_response import ApiListPoolsResponse +from opensandbox.api.lifecycle.types import Unset +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.models.pools import ( + CreatePoolParams, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + PoolStatus, + UpdatePoolParams, +) +from opensandbox.sync.services.pool import PoolsSync + +logger = logging.getLogger(__name__) + + +class PoolsAdapterSync(PoolsSync): + """ + Synchronous HTTP adapter implementing the PoolsSync service interface. + + Uses an httpx sync client backed by ConnectionConfigSync to make + blocking HTTP calls to the lifecycle API's /pools endpoints. + """ + + def __init__(self, connection_config: ConnectionConfigSync) -> None: + self._connection_config = connection_config + api_key = connection_config.get_api_key() + timeout_seconds = connection_config.request_timeout.total_seconds() + timeout = httpx.Timeout(timeout_seconds) + headers = { + "User-Agent": connection_config.user_agent, + **connection_config.headers, + } + if api_key: + headers["OPEN-SANDBOX-API-KEY"] = api_key + + self._client = AuthenticatedClient( + base_url=connection_config.get_base_url(), + token=api_key or "", + prefix="", + auth_header_name="OPEN-SANDBOX-API-KEY", + timeout=timeout, + ) + self._httpx_client = httpx.Client( + base_url=connection_config.get_base_url(), + headers=headers, + timeout=timeout, + transport=connection_config.transport, + ) + self._client.set_httpx_client(self._httpx_client) + + # ------------------------------------------------------------------ + # Conversion helpers (identical logic to async PoolsAdapter) + # ------------------------------------------------------------------ + + @staticmethod + def _to_api_capacity(spec: PoolCapacitySpec) -> ApiPoolCapacitySpec: + return ApiPoolCapacitySpec( + buffer_max=spec.buffer_max, + buffer_min=spec.buffer_min, + pool_max=spec.pool_max, + pool_min=spec.pool_min, + ) + + @staticmethod + def _from_api_pool(raw: ApiPoolResponse) -> PoolInfo: + cap = raw.capacity_spec + capacity_spec = PoolCapacitySpec( + bufferMax=cap.buffer_max, + bufferMin=cap.buffer_min, + poolMax=cap.pool_max, + poolMin=cap.pool_min, + ) + status = None + if not isinstance(raw.status, Unset) and raw.status is not None: + s = raw.status + status = PoolStatus( + total=s.total, + allocated=s.allocated, + available=s.available, + revision=s.revision, + ) + created_at = None + if not isinstance(raw.created_at, Unset): + created_at = raw.created_at + + return PoolInfo( + name=raw.name, + capacitySpec=capacity_spec, + status=status, + createdAt=created_at, + ) + + # ------------------------------------------------------------------ + # Public API (blocking) + # ------------------------------------------------------------------ + + def create_pool(self, params: CreatePoolParams) -> PoolInfo: + logger.info("Creating pool (sync): name=%s", params.name) + try: + from opensandbox.api.lifecycle.api.pools import post_pools + + body = ApiCreatePoolRequest( + name=params.name, + template=params.template, + capacity_spec=self._to_api_capacity(params.capacity_spec), + ) + response_obj = post_pools.sync_detailed(client=self._client, body=body) + handle_api_error(response_obj, f"Create pool '{params.name}'") + parsed = require_parsed(response_obj, ApiPoolResponse, f"Create pool '{params.name}'") + result = self._from_api_pool(parsed) + logger.info("Successfully created pool (sync): %s", result.name) + return result + except Exception as e: + logger.error("Failed to create pool (sync) '%s'", params.name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + def get_pool(self, pool_name: str) -> PoolInfo: + logger.debug("Getting pool (sync): %s", pool_name) + try: + from opensandbox.api.lifecycle.api.pools import get_pools_pool_name + + response_obj = get_pools_pool_name.sync_detailed(pool_name, client=self._client) + handle_api_error(response_obj, f"Get pool '{pool_name}'") + parsed = require_parsed(response_obj, ApiPoolResponse, f"Get pool '{pool_name}'") + return self._from_api_pool(parsed) + except Exception as e: + logger.error("Failed to get pool (sync) '%s'", pool_name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + def list_pools(self) -> PoolListResponse: + logger.debug("Listing pools (sync)") + try: + from opensandbox.api.lifecycle.api.pools import get_pools + + response_obj = get_pools.sync_detailed(client=self._client) + handle_api_error(response_obj, "List pools") + parsed = require_parsed(response_obj, ApiListPoolsResponse, "List pools") + items = [self._from_api_pool(item) for item in parsed.items] + return PoolListResponse(items=items) + except Exception as e: + logger.error("Failed to list pools (sync)", exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + def update_pool(self, pool_name: str, params: UpdatePoolParams) -> PoolInfo: + logger.info("Updating pool capacity (sync): %s", pool_name) + try: + from opensandbox.api.lifecycle.api.pools import put_pools_pool_name + + body = ApiUpdatePoolRequest( + capacity_spec=self._to_api_capacity(params.capacity_spec) + ) + response_obj = put_pools_pool_name.sync_detailed( + pool_name, client=self._client, body=body + ) + handle_api_error(response_obj, f"Update pool '{pool_name}'") + parsed = require_parsed(response_obj, ApiPoolResponse, f"Update pool '{pool_name}'") + result = self._from_api_pool(parsed) + logger.info("Successfully updated pool (sync): %s", pool_name) + return result + except Exception as e: + logger.error("Failed to update pool (sync) '%s'", pool_name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + def delete_pool(self, pool_name: str) -> None: + logger.info("Deleting pool (sync): %s", pool_name) + try: + from opensandbox.api.lifecycle.api.pools import delete_pools_pool_name + + response_obj = delete_pools_pool_name.sync_detailed(pool_name, client=self._client) + handle_api_error(response_obj, f"Delete pool '{pool_name}'") + logger.info("Successfully deleted pool (sync): %s", pool_name) + except Exception as e: + logger.error("Failed to delete pool (sync) '%s'", pool_name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/src/opensandbox/sync/pool_manager.py b/sdks/sandbox/python/src/opensandbox/sync/pool_manager.py new file mode 100644 index 00000000..1ecbb0a8 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/sync/pool_manager.py @@ -0,0 +1,272 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# 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. +# +""" +Synchronous PoolManager implementation. + +Provides a high-level **blocking** interface for creating and managing +pre-warmed sandbox resource pools. Use PoolManagerSync when you prefer +synchronous code or when running outside of an async event loop. + +Usage:: + + from opensandbox.sync.pool_manager import PoolManagerSync + + with PoolManagerSync.create() as manager: + pool = manager.create_pool( + name="my-pool", + template={"spec": {"containers": [{"name": "sbx", "image": "python:3.11"}]}}, + buffer_max=3, buffer_min=1, pool_max=10, pool_min=0, + ) + print(pool.name, pool.status) +""" + +from __future__ import annotations + +import logging +from typing import Any + +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.models.pools import ( + CreatePoolParams, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + UpdatePoolParams, +) +from opensandbox.sync.adapters.factory import AdapterFactorySync +from opensandbox.sync.services.pool import PoolsSync + +logger = logging.getLogger(__name__) + + +class PoolManagerSync: + """ + High-level synchronous interface for managing pre-warmed sandbox resource pools. + + Pools are Kubernetes CRD resources that keep a set of pods pre-warmed, + reducing sandbox cold-start latency. This manager exposes simple CRUD + methods that map to the server's ``/pools`` API. All methods **block** + until the underlying HTTP call completes. + + **Creating a manager**:: + + manager = PoolManagerSync.create(ConnectionConfigSync(api_key="...")) + + **Using as a context manager** (recommended for automatic cleanup):: + + with PoolManagerSync.create() as manager: + pool = manager.create_pool( + name="my-pool", + template={...}, + buffer_max=3, buffer_min=1, pool_max=10, pool_min=0, + ) + + **Cleanup**: Call ``manager.close()`` (or use the context manager) to + release the internal httpx transport. + + **Note**: Pool management requires the server to be running with + ``runtime.type = 'kubernetes'``. Non-Kubernetes deployments return a + ``SandboxApiException`` with status 501. + """ + + def __init__( + self, + pool_service: PoolsSync, + connection_config: ConnectionConfigSync, + ) -> None: + """ + Internal constructor. Use :meth:`create` instead. + + Args: + pool_service: Synchronous pool service implementation. + connection_config: Connection configuration (shared transport, headers, timeouts). + """ + self._pool_service = pool_service + self._connection_config = connection_config + + @property + def connection_config(self) -> ConnectionConfigSync: + """Connection configuration used by this manager.""" + return self._connection_config + + @classmethod + def create( + cls, connection_config: ConnectionConfigSync | None = None + ) -> "PoolManagerSync": + """ + Create a PoolManagerSync with the provided (or default) connection config. + + Args: + connection_config: Connection configuration. If ``None``, the default + configuration (env vars / defaults) is used. + + Returns: + Configured PoolManagerSync instance. + """ + config = (connection_config or ConnectionConfigSync()).with_transport_if_missing() + factory = AdapterFactorySync(config) + pool_service = factory.create_pool_service() + return cls(pool_service, config) + + # ------------------------------------------------------------------ + # Pool CRUD (blocking) + # ------------------------------------------------------------------ + + def create_pool( + self, + name: str, + template: dict[str, Any], + *, + buffer_max: int, + buffer_min: int, + pool_max: int, + pool_min: int, + ) -> PoolInfo: + """ + Create a new pre-warmed resource pool (blocking). + + Args: + name: Unique pool name (must be a valid Kubernetes resource name). + template: Kubernetes PodTemplateSpec dict for the pre-warmed pods. + buffer_max: Maximum number of pods in the warm buffer. + buffer_min: Minimum number of pods in the warm buffer. + pool_max: Maximum total pool size. + pool_min: Minimum total pool size. + + Returns: + PoolInfo representing the newly created pool. + + Raises: + SandboxException: If the operation fails. + """ + params = CreatePoolParams( + name=name, + template=template, + capacitySpec=PoolCapacitySpec( + bufferMax=buffer_max, + bufferMin=buffer_min, + poolMax=pool_max, + poolMin=pool_min, + ), + ) + logger.info("Creating pool (sync): %s", name) + return self._pool_service.create_pool(params) + + def get_pool(self, pool_name: str) -> PoolInfo: + """ + Retrieve a pool by name (blocking). + + Args: + pool_name: Name of the pool to retrieve. + + Returns: + Current PoolInfo including observed runtime status. + + Raises: + SandboxException: If the operation fails. + """ + logger.debug("Getting pool (sync): %s", pool_name) + return self._pool_service.get_pool(pool_name) + + def list_pools(self) -> PoolListResponse: + """ + List all pools (blocking). + + Returns: + PoolListResponse containing all pools in the namespace. + + Raises: + SandboxException: If the operation fails. + """ + logger.debug("Listing pools (sync)") + return self._pool_service.list_pools() + + def update_pool( + self, + pool_name: str, + *, + buffer_max: int, + buffer_min: int, + pool_max: int, + pool_min: int, + ) -> PoolInfo: + """ + Update the capacity configuration of an existing pool (blocking). + + Only capacity values can be changed after creation. To change the + pod template, delete and recreate the pool. + + Args: + pool_name: Name of the pool to update. + buffer_max: New maximum warm-buffer size. + buffer_min: New minimum warm-buffer size. + pool_max: New maximum total pool size. + pool_min: New minimum total pool size. + + Returns: + Updated PoolInfo. + + Raises: + SandboxException: If the operation fails. + """ + params = UpdatePoolParams( + capacitySpec=PoolCapacitySpec( + bufferMax=buffer_max, + bufferMin=buffer_min, + poolMax=pool_max, + poolMin=pool_min, + ) + ) + logger.info("Updating pool capacity (sync): %s", pool_name) + return self._pool_service.update_pool(pool_name, params) + + def delete_pool(self, pool_name: str) -> None: + """ + Delete a pool (blocking). + + Args: + pool_name: Name of the pool to delete. + + Raises: + SandboxException: If the operation fails. + """ + logger.info("Deleting pool (sync): %s", pool_name) + self._pool_service.delete_pool(pool_name) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """ + Release HTTP client resources owned by this manager. + + Logs but does not re-raise errors so ``__exit__`` is always safe. + """ + try: + self._connection_config.close_transport_if_owned() + except Exception as e: + logger.warning( + "Error closing PoolManagerSync resources: %s", e, exc_info=True + ) + + def __enter__(self) -> "PoolManagerSync": + """Sync context manager entry.""" + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Sync context manager exit.""" + self.close() diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py b/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py index 283d7e47..121f1d19 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py @@ -21,6 +21,7 @@ from opensandbox.sync.services.filesystem import FilesystemSync from opensandbox.sync.services.health import HealthSync from opensandbox.sync.services.metrics import MetricsSync +from opensandbox.sync.services.pool import PoolsSync from opensandbox.sync.services.sandbox import SandboxesSync __all__ = [ @@ -28,5 +29,6 @@ "FilesystemSync", "HealthSync", "MetricsSync", + "PoolsSync", "SandboxesSync", ] diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/pool.py b/sdks/sandbox/python/src/opensandbox/sync/services/pool.py new file mode 100644 index 00000000..ab1a39e1 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/sync/services/pool.py @@ -0,0 +1,109 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# 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. +# +""" +Synchronous pool service interface. + +Defines the contract for **blocking** pool lifecycle operations. +This is the sync counterpart of :mod:`opensandbox.services.pool`. +""" + +from typing import Protocol + +from opensandbox.models.pools import ( + CreatePoolParams, + PoolInfo, + PoolListResponse, + UpdatePoolParams, +) + + +class PoolsSync(Protocol): + """ + Core pool lifecycle management service (sync). + + Provides a clean abstraction over pool creation, management and deletion, + isolating business logic from API implementation details. + """ + + def create_pool(self, params: CreatePoolParams) -> PoolInfo: + """ + Create a new pre-warmed resource pool (blocking). + + Args: + params: Pool creation parameters. + + Returns: + PoolInfo representing the newly created pool. + + Raises: + SandboxException: If the operation fails. + """ + ... + + def get_pool(self, pool_name: str) -> PoolInfo: + """ + Retrieve a pool by name (blocking). + + Args: + pool_name: Name of the pool to retrieve. + + Returns: + Current PoolInfo including observed runtime status. + + Raises: + SandboxException: If the operation fails (404 if not found). + """ + ... + + def list_pools(self) -> PoolListResponse: + """ + List all pools in the namespace (blocking). + + Returns: + PoolListResponse containing all pools. + + Raises: + SandboxException: If the operation fails. + """ + ... + + def update_pool(self, pool_name: str, params: UpdatePoolParams) -> PoolInfo: + """ + Update the capacity configuration of an existing pool (blocking). + + Args: + pool_name: Name of the pool to update. + params: New capacity configuration. + + Returns: + Updated PoolInfo. + + Raises: + SandboxException: If the operation fails (404 if not found). + """ + ... + + def delete_pool(self, pool_name: str) -> None: + """ + Delete a pool (blocking). + + Args: + pool_name: Name of the pool to delete. + + Raises: + SandboxException: If the operation fails (404 if not found). + """ + ... diff --git a/sdks/sandbox/python/tests/test_pool_manager_sync.py b/sdks/sandbox/python/tests/test_pool_manager_sync.py new file mode 100644 index 00000000..3d027ea1 --- /dev/null +++ b/sdks/sandbox/python/tests/test_pool_manager_sync.py @@ -0,0 +1,865 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# 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. +# +""" +Tests for the synchronous Pool SDK layer: + - PoolsSync Protocol (sync/services/pool.py) + - PoolsAdapterSync (sync/adapters/pools_adapter.py) + - PoolManagerSync (sync/pool_manager.py) + - AdapterFactorySync.create_pool_service() (sync/adapters/factory.py) + - Public export via opensandbox.sync and opensandbox root +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from opensandbox.api.lifecycle.models.list_pools_response import ApiListPoolsResponse +from opensandbox.api.lifecycle.models.pool_capacity_spec import ApiPoolCapacitySpec +from opensandbox.api.lifecycle.models.pool_response import ApiPoolResponse +from opensandbox.api.lifecycle.models.pool_status import ApiPoolStatus +from opensandbox.api.lifecycle.types import UNSET, Response +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.exceptions import SandboxApiException +from opensandbox.models.pools import ( + CreatePoolParams, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + PoolStatus, + UpdatePoolParams, +) +from opensandbox.sync.pool_manager import PoolManagerSync +from opensandbox.sync.services.pool import PoolsSync + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +def _make_capacity_spec( + buffer_max: int = 3, buffer_min: int = 1, pool_max: int = 10, pool_min: int = 0 +) -> ApiPoolCapacitySpec: + return ApiPoolCapacitySpec( + buffer_max=buffer_max, buffer_min=buffer_min, + pool_max=pool_max, pool_min=pool_min, + ) + + +def _make_pool_response( + name: str = "test-pool", + buffer_max: int = 3, + pool_max: int = 10, + with_status: bool = True, + with_created: bool = True, +) -> ApiPoolResponse: + status = ( + ApiPoolStatus(total=2, allocated=1, available=1, revision="rev-1") + if with_status else UNSET + ) + created_at = ( + datetime(2025, 6, 1, tzinfo=timezone.utc) if with_created else UNSET + ) + return ApiPoolResponse( + name=name, + capacity_spec=_make_capacity_spec(buffer_max=buffer_max, pool_max=pool_max), + status=status, + created_at=created_at, + ) + + +def _make_http_response(status_code: int, parsed: Any) -> Response: + """Wrap a parsed object in the API Response envelope.""" + return Response( + status_code=HTTPStatus(status_code), + content=b"", + headers={}, + parsed=parsed, + ) + + +# --------------------------------------------------------------------------- +# Stub PoolsSync service +# --------------------------------------------------------------------------- + +class _PoolServiceSyncStub(PoolsSync): + """In-memory PoolsSync stub — no HTTP calls.""" + + def __init__(self) -> None: + self._pools: dict[str, PoolInfo] = {} + self.create_calls: list[CreatePoolParams] = [] + self.update_calls: list[tuple[str, UpdatePoolParams]] = [] + self.delete_calls: list[str] = [] + + def _make_pool(self, name: str, cap: PoolCapacitySpec) -> PoolInfo: + return PoolInfo( + name=name, + capacitySpec=cap, + status=PoolStatus(total=0, allocated=0, available=0, revision="init"), + ) + + def create_pool(self, params: CreatePoolParams) -> PoolInfo: + self.create_calls.append(params) + pool = self._make_pool(params.name, params.capacity_spec) + self._pools[params.name] = pool + return pool + + def get_pool(self, pool_name: str) -> PoolInfo: + if pool_name not in self._pools: + raise SandboxApiException(message=f"Pool '{pool_name}' not found.", status_code=404) + return self._pools[pool_name] + + def list_pools(self) -> PoolListResponse: + return PoolListResponse(items=list(self._pools.values())) + + def update_pool(self, pool_name: str, params: UpdatePoolParams) -> PoolInfo: + self.update_calls.append((pool_name, params)) + if pool_name not in self._pools: + raise SandboxApiException(message=f"Pool '{pool_name}' not found.", status_code=404) + updated = self._make_pool(pool_name, params.capacity_spec) + self._pools[pool_name] = updated + return updated + + def delete_pool(self, pool_name: str) -> None: + self.delete_calls.append(pool_name) + if pool_name not in self._pools: + raise SandboxApiException(message=f"Pool '{pool_name}' not found.", status_code=404) + del self._pools[pool_name] + + +def _make_manager() -> tuple[PoolManagerSync, _PoolServiceSyncStub]: + stub = _PoolServiceSyncStub() + manager = PoolManagerSync(stub, ConnectionConfigSync()) + return manager, stub + + +# =========================================================================== +# PoolsSync Protocol structural test +# =========================================================================== + +class TestPoolsSyncProtocol: + def test_protocol_methods_present(self): + """The Protocol exposes the five expected methods.""" + methods = {"create_pool", "get_pool", "list_pools", "update_pool", "delete_pool"} + assert methods.issubset(dir(PoolsSync)) + + def test_stub_satisfies_protocol(self): + """_PoolServiceSyncStub satisfies the PoolsSync structural Protocol.""" + stub = _PoolServiceSyncStub() + # Runtime isinstance check via typing.runtime_checkable is not mandatory + # (Protocol is not decorated with @runtime_checkable), but we can confirm + # the attribute surface matches. + for method in ("create_pool", "get_pool", "list_pools", "update_pool", "delete_pool"): + assert callable(getattr(stub, method, None)), f"Missing: {method}" + + +# =========================================================================== +# PoolManagerSync.create_pool +# =========================================================================== + +class TestPoolManagerSyncCreatePool: + def test_returns_pool_info_with_correct_fields(self): + manager, _ = _make_manager() + pool = manager.create_pool( + name="ci-pool", + template={"spec": {}}, + buffer_max=3, buffer_min=1, pool_max=10, pool_min=0, + ) + assert pool.name == "ci-pool" + assert pool.capacity_spec.buffer_max == 3 + assert pool.capacity_spec.pool_max == 10 + + def test_delegates_full_request_to_service(self): + manager, stub = _make_manager() + manager.create_pool( + name="my-pool", + template={"spec": {"containers": []}}, + buffer_max=5, buffer_min=2, pool_max=20, pool_min=1, + ) + assert len(stub.create_calls) == 1 + params = stub.create_calls[0] + assert params.name == "my-pool" + assert params.capacity_spec.buffer_max == 5 + assert params.capacity_spec.pool_min == 1 + + def test_propagates_sandbox_api_exception_on_conflict(self): + manager, stub = _make_manager() + stub.create_pool = MagicMock( + side_effect=SandboxApiException(message="already exists", status_code=409) + ) + with pytest.raises(SandboxApiException) as exc_info: + manager.create_pool( + name="dup", + template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + assert exc_info.value.status_code == 409 + + def test_propagates_sandbox_api_exception_on_501(self): + manager, stub = _make_manager() + stub.create_pool = MagicMock( + side_effect=SandboxApiException(message="not supported", status_code=501) + ) + with pytest.raises(SandboxApiException): + manager.create_pool( + name="p", + template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + + def test_template_passed_through(self): + manager, stub = _make_manager() + tmpl = {"spec": {"containers": [{"name": "sbx", "image": "python:3.11"}]}} + manager.create_pool( + name="p", + template=tmpl, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + assert stub.create_calls[0].template == tmpl + + +# =========================================================================== +# PoolManagerSync.get_pool +# =========================================================================== + +class TestPoolManagerSyncGetPool: + def test_returns_existing_pool(self): + manager, _ = _make_manager() + manager.create_pool( + name="p1", template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + pool = manager.get_pool("p1") + assert pool.name == "p1" + + def test_raises_404_for_missing_pool(self): + manager, _ = _make_manager() + with pytest.raises(SandboxApiException) as exc_info: + manager.get_pool("ghost") + assert exc_info.value.status_code == 404 + + def test_delegates_with_correct_pool_name(self): + manager, stub = _make_manager() + stub.get_pool = MagicMock( + return_value=PoolInfo( + name="target", + capacitySpec=PoolCapacitySpec( + bufferMax=1, bufferMin=0, poolMax=5, poolMin=0 + ), + ) + ) + manager.get_pool("target") + stub.get_pool.assert_called_once_with("target") + + def test_returns_pool_with_status(self): + manager, stub = _make_manager() + expected_pool = PoolInfo( + name="p", + capacitySpec=PoolCapacitySpec(bufferMax=3, bufferMin=1, poolMax=10, poolMin=0), + status=PoolStatus(total=5, allocated=2, available=3, revision="v1"), + ) + stub.get_pool = MagicMock(return_value=expected_pool) + pool = manager.get_pool("p") + assert pool.status is not None + assert pool.status.total == 5 + assert pool.status.revision == "v1" + + +# =========================================================================== +# PoolManagerSync.list_pools +# =========================================================================== + +class TestPoolManagerSyncListPools: + def test_returns_empty_list_when_no_pools(self): + manager, _ = _make_manager() + result = manager.list_pools() + assert result.items == [] + + def test_returns_all_pools(self): + manager, _ = _make_manager() + for name in ("a", "b", "c"): + manager.create_pool( + name=name, template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + result = manager.list_pools() + assert len(result.items) == 3 + names = {p.name for p in result.items} + assert names == {"a", "b", "c"} + + def test_delegates_to_pool_service(self): + manager, stub = _make_manager() + stub.list_pools = MagicMock(return_value=PoolListResponse(items=[])) + manager.list_pools() + stub.list_pools.assert_called_once() + + def test_raises_501_on_non_kubernetes(self): + manager, stub = _make_manager() + stub.list_pools = MagicMock( + side_effect=SandboxApiException(message="not supported", status_code=501) + ) + with pytest.raises(SandboxApiException) as exc_info: + manager.list_pools() + assert exc_info.value.status_code == 501 + + +# =========================================================================== +# PoolManagerSync.update_pool +# =========================================================================== + +class TestPoolManagerSyncUpdatePool: + def test_updates_capacity_and_returns_new_pool_info(self): + manager, _ = _make_manager() + manager.create_pool( + name="p", template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + updated = manager.update_pool( + "p", buffer_max=9, buffer_min=3, pool_max=50, pool_min=0, + ) + assert updated.capacity_spec.buffer_max == 9 + assert updated.capacity_spec.pool_max == 50 + + def test_delegates_with_correct_pool_name_and_params(self): + manager, stub = _make_manager() + manager.create_pool( + name="p", template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + manager.update_pool( + "p", buffer_max=7, buffer_min=2, pool_max=30, pool_min=0, + ) + assert len(stub.update_calls) == 1 + pool_name, params = stub.update_calls[0] + assert pool_name == "p" + assert params.capacity_spec.buffer_max == 7 + assert params.capacity_spec.pool_max == 30 + + def test_raises_404_for_missing_pool(self): + manager, _ = _make_manager() + with pytest.raises(SandboxApiException) as exc_info: + manager.update_pool( + "ghost", buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + assert exc_info.value.status_code == 404 + + def test_all_capacity_fields_forwarded(self): + manager, stub = _make_manager() + manager.create_pool( + name="p", template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + manager.update_pool( + "p", buffer_max=7, buffer_min=3, pool_max=30, pool_min=2, + ) + _, params = stub.update_calls[0] + cap = params.capacity_spec + assert cap.buffer_max == 7 + assert cap.buffer_min == 3 + assert cap.pool_max == 30 + assert cap.pool_min == 2 + + +# =========================================================================== +# PoolManagerSync.delete_pool +# =========================================================================== + +class TestPoolManagerSyncDeletePool: + def test_deletes_existing_pool(self): + manager, stub = _make_manager() + manager.create_pool( + name="bye", template={}, + buffer_max=1, buffer_min=0, pool_max=5, pool_min=0, + ) + manager.delete_pool("bye") + assert "bye" in stub.delete_calls + assert "bye" not in stub._pools + + def test_delegates_with_correct_pool_name(self): + manager, stub = _make_manager() + stub.delete_pool = MagicMock(return_value=None) + manager.delete_pool("to-delete") + stub.delete_pool.assert_called_once_with("to-delete") + + def test_raises_404_for_missing_pool(self): + manager, _ = _make_manager() + with pytest.raises(SandboxApiException) as exc_info: + manager.delete_pool("ghost") + assert exc_info.value.status_code == 404 + + def test_returns_none_on_success(self): + manager, stub = _make_manager() + stub.delete_pool = MagicMock(return_value=None) + result = manager.delete_pool("p") + assert result is None + + +# =========================================================================== +# PoolManagerSync.create factory +# =========================================================================== + +class TestPoolManagerSyncFactory: + def test_create_returns_instance(self): + manager = PoolManagerSync.create(ConnectionConfigSync()) + assert isinstance(manager, PoolManagerSync) + manager.close() + + def test_create_without_args_uses_defaults(self): + manager = PoolManagerSync.create() + assert isinstance(manager, PoolManagerSync) + manager.close() + + def test_create_with_api_key(self): + config = ConnectionConfigSync(api_key="secret-key") + manager = PoolManagerSync.create(config) + assert manager.connection_config.get_api_key() == "secret-key" + manager.close() + + def test_create_with_domain(self): + config = ConnectionConfigSync(domain="api.example.com") + manager = PoolManagerSync.create(config) + assert "api.example.com" in manager.connection_config.get_base_url() + manager.close() + + def test_exposes_all_crud_methods(self): + manager = PoolManagerSync.create() + for method in ("create_pool", "get_pool", "list_pools", "update_pool", "delete_pool", "close"): + assert callable(getattr(manager, method, None)), f"Missing method: {method}" + manager.close() + + +# =========================================================================== +# PoolManagerSync.close / context manager +# =========================================================================== + +class TestPoolManagerSyncLifecycle: + def test_close_calls_transport_cleanup(self): + stub = _PoolServiceSyncStub() + config = ConnectionConfigSync() + manager = PoolManagerSync(stub, config) + + with patch.object(ConnectionConfigSync, "close_transport_if_owned") as mock_close: + manager.close() + mock_close.assert_called_once() + + def test_close_does_not_raise_on_error(self): + stub = _PoolServiceSyncStub() + config = ConnectionConfigSync() + manager = PoolManagerSync(stub, config) + + with patch.object( + ConnectionConfigSync, "close_transport_if_owned", side_effect=RuntimeError("oops") + ): + # Should swallow the exception and not propagate it. + manager.close() + + def test_context_manager_calls_close(self): + stub = _PoolServiceSyncStub() + config = ConnectionConfigSync() + manager = PoolManagerSync(stub, config) + + with patch.object(ConnectionConfigSync, "close_transport_if_owned") as mock_close: + with manager: + pass + mock_close.assert_called_once() + + def test_context_manager_exits_on_exception(self): + stub = _PoolServiceSyncStub() + config = ConnectionConfigSync() + manager = PoolManagerSync(stub, config) + + with patch.object(ConnectionConfigSync, "close_transport_if_owned") as mock_close: + with pytest.raises(ValueError): + with manager: + raise ValueError("inner error") + # close() must still be called even when an exception was raised. + mock_close.assert_called_once() + + def test_context_manager_returns_self(self): + manager, _ = _make_manager() + with manager as m: + assert m is manager + + +# =========================================================================== +# PoolsAdapterSync HTTP behaviour (patching sync_detailed calls) +# =========================================================================== + +class TestPoolsAdapterSync: + """ + Tests that exercise PoolsAdapterSync method signatures and HTTP routing, + by patching the module-level sync_detailed functions in the API layer. + """ + + def _make_config(self) -> ConnectionConfigSync: + return ConnectionConfigSync(api_key="test-key").with_transport_if_missing() + + def test_create_pool_calls_post_pools_sync_detailed(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("new-pool") + resp = _make_http_response(201, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.post_pools.sync_detailed", + return_value=resp, + ) as mock_call: + result = adapter.create_pool( + CreatePoolParams( + name="new-pool", + template={}, + capacitySpec=PoolCapacitySpec( + bufferMax=3, bufferMin=1, poolMax=10, poolMin=0 + ), + ) + ) + mock_call.assert_called_once() + assert result.name == "new-pool" + + def test_get_pool_calls_get_pools_pool_name_sync_detailed(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("p1") + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ) as mock_call: + result = adapter.get_pool("p1") + mock_call.assert_called_once() + assert result.name == "p1" + + def test_list_pools_calls_get_pools_sync_detailed(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw_list = ApiListPoolsResponse( + items=[_make_pool_response("a"), _make_pool_response("b")] + ) + resp = _make_http_response(200, raw_list) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools.sync_detailed", + return_value=resp, + ) as mock_call: + result = adapter.list_pools() + mock_call.assert_called_once() + assert len(result.items) == 2 + + def test_update_pool_calls_put_pools_pool_name_sync_detailed(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("p", buffer_max=9, pool_max=50) + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.put_pools_pool_name.sync_detailed", + return_value=resp, + ) as mock_call: + result = adapter.update_pool( + "p", + UpdatePoolParams( + capacitySpec=PoolCapacitySpec( + bufferMax=9, bufferMin=3, poolMax=50, poolMin=0 + ) + ), + ) + mock_call.assert_called_once() + assert result.capacity_spec.buffer_max == 9 + assert result.capacity_spec.pool_max == 50 + + def test_delete_pool_calls_delete_pools_pool_name_sync_detailed(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + resp = _make_http_response(204, None) + + with patch( + "opensandbox.api.lifecycle.api.pools.delete_pools_pool_name.sync_detailed", + return_value=resp, + ) as mock_call: + result = adapter.delete_pool("bye") + mock_call.assert_called_once() + assert result is None + + def test_create_pool_raises_sandbox_api_exception_on_409(self): + from opensandbox.api.lifecycle.models.error_response import ErrorResponse + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + err_body = ErrorResponse(code="POOL_ALREADY_EXISTS", message="already exists") + resp = _make_http_response(409, err_body) + + with patch( + "opensandbox.api.lifecycle.api.pools.post_pools.sync_detailed", + return_value=resp, + ): + with pytest.raises(SandboxApiException): + adapter.create_pool( + CreatePoolParams( + name="dup", + template={}, + capacitySpec=PoolCapacitySpec( + bufferMax=1, bufferMin=0, poolMax=5, poolMin=0 + ), + ) + ) + + def test_get_pool_raises_sandbox_api_exception_on_404(self): + from opensandbox.api.lifecycle.models.error_response import ErrorResponse + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + err_body = ErrorResponse(code="NOT_FOUND", message="not found") + resp = _make_http_response(404, err_body) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ): + with pytest.raises(SandboxApiException): + adapter.get_pool("ghost") + + def test_list_pools_raises_on_501(self): + from opensandbox.api.lifecycle.models.error_response import ErrorResponse + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + err_body = ErrorResponse(code="NOT_SUPPORTED", message="non-k8s") + resp = _make_http_response(501, err_body) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools.sync_detailed", + return_value=resp, + ): + with pytest.raises(SandboxApiException): + adapter.list_pools() + + def test_delete_pool_raises_on_404(self): + from opensandbox.api.lifecycle.models.error_response import ErrorResponse + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + err_body = ErrorResponse(code="NOT_FOUND", message="not found") + resp = _make_http_response(404, err_body) + + with patch( + "opensandbox.api.lifecycle.api.pools.delete_pools_pool_name.sync_detailed", + return_value=resp, + ): + with pytest.raises(SandboxApiException): + adapter.delete_pool("ghost") + + +# =========================================================================== +# PoolsAdapterSync mapping helpers (capacitySpec / status / createdAt) +# =========================================================================== + +class TestPoolsAdapterSyncMapping: + """Test the internal response mapping via create_pool/get_pool/list_pools.""" + + def _make_config(self) -> ConnectionConfigSync: + return ConnectionConfigSync(api_key="test-key").with_transport_if_missing() + + def test_capacity_spec_all_fields_mapped(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = ApiPoolResponse( + name="p", + capacity_spec=ApiPoolCapacitySpec( + buffer_max=7, buffer_min=3, pool_max=30, pool_min=2 + ), + status=UNSET, + created_at=UNSET, + ) + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ): + result = adapter.get_pool("p") + + assert result.capacity_spec.buffer_max == 7 + assert result.capacity_spec.buffer_min == 3 + assert result.capacity_spec.pool_max == 30 + assert result.capacity_spec.pool_min == 2 + + def test_status_mapped_when_present(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("p", with_status=True) + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ): + result = adapter.get_pool("p") + + assert result.status is not None + assert result.status.total == 2 + assert result.status.allocated == 1 + assert result.status.available == 1 + assert result.status.revision == "rev-1" + + def test_status_is_none_when_absent(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("p", with_status=False) + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ): + result = adapter.get_pool("p") + + assert result.status is None + + def test_created_at_mapped_when_present(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("p", with_created=True) + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ): + result = adapter.get_pool("p") + + assert result.created_at is not None + assert isinstance(result.created_at, datetime) + assert result.created_at.year == 2025 + + def test_created_at_is_none_when_absent(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw = _make_pool_response("p", with_created=False) + resp = _make_http_response(200, raw) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools_pool_name.sync_detailed", + return_value=resp, + ): + result = adapter.get_pool("p") + + assert result.created_at is None + + def test_list_pools_maps_each_item(self): + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = self._make_config() + adapter = PoolsAdapterSync(config) + raw_list = ApiListPoolsResponse( + items=[ + _make_pool_response("pool-a", with_status=True), + _make_pool_response("pool-b", with_status=False, with_created=False), + ] + ) + resp = _make_http_response(200, raw_list) + + with patch( + "opensandbox.api.lifecycle.api.pools.get_pools.sync_detailed", + return_value=resp, + ): + result = adapter.list_pools() + + assert len(result.items) == 2 + assert result.items[0].name == "pool-a" + assert result.items[0].status is not None + assert result.items[1].name == "pool-b" + assert result.items[1].status is None + assert result.items[1].created_at is None + + +# =========================================================================== +# AdapterFactorySync.create_pool_service +# =========================================================================== + +class TestAdapterFactorySyncPoolService: + def test_create_pool_service_returns_pools_adapter_sync(self): + from opensandbox.sync.adapters.factory import AdapterFactorySync + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = ConnectionConfigSync().with_transport_if_missing() + factory = AdapterFactorySync(config) + svc = factory.create_pool_service() + assert isinstance(svc, PoolsAdapterSync) + + def test_create_pool_service_honours_connection_config(self): + from opensandbox.sync.adapters.factory import AdapterFactorySync + from opensandbox.sync.adapters.pools_adapter import PoolsAdapterSync + + config = ConnectionConfigSync(api_key="abc").with_transport_if_missing() + factory = AdapterFactorySync(config) + svc = factory.create_pool_service() + # Confirm an adapter was returned (API key embedded inside httpx client) + assert isinstance(svc, PoolsAdapterSync) + + +# =========================================================================== +# Public exports +# =========================================================================== + +class TestPublicExports: + def test_pool_manager_sync_exported_from_sync_module(self): + from opensandbox import sync as sync_module + assert hasattr(sync_module, "PoolManagerSync") + + def test_pool_manager_sync_exported_from_root(self): + import opensandbox + assert hasattr(opensandbox, "PoolManagerSync") + + def test_pool_manager_sync_in_root_all(self): + import opensandbox + assert "PoolManagerSync" in opensandbox.__all__ + + def test_pool_manager_sync_in_sync_all(self): + from opensandbox import sync as sync_module + assert "PoolManagerSync" in sync_module.__all__ + + def test_pool_manager_sync_importable_directly(self): + from opensandbox.sync.pool_manager import PoolManagerSync as PM + assert PM is PoolManagerSync