diff --git a/CHANGELOG.md b/CHANGELOG.md index 483e5d85..bcfbc3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1621,5 +1621,3 @@ * @idelsink made their first contribution * @dependabot[bot] made their first contribution * @ari-nz made their first contribution - - diff --git a/pyproject.toml b/pyproject.toml index 0348bf29..05686bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,8 @@ jupyter = [ "jupyter-core>=5.8.1", # CVE-2025-30167 "jupyterlab>=4.4.9", # CVE-2025-59842 "nbconvert>=7.17.1", # CVE-2025-53000 (>=7.17.0, Dependabot #424); CVE-2026-39377, CVE-2026-39378 (>=7.17.1, Dependabot #553) + "jupyter-server>=2.18.0", # CVE-2025-61669, CVE-2026-40110, CVE-2026-35397, CVE-2026-40934 + "notebook>=7.5.6", # CVE-2026-40171 ] marimo = [ "cloudpathlib>=0.23.0,<1", diff --git a/specifications/SPEC_PLATFORM_SERVICE.md b/specifications/SPEC_PLATFORM_SERVICE.md index bde11c0e..764cf8e6 100644 --- a/specifications/SPEC_PLATFORM_SERVICE.md +++ b/specifications/SPEC_PLATFORM_SERVICE.md @@ -32,6 +32,7 @@ The Platform Module shall: - **[FR-11]** Download and verify file integrity using CRC32C checksums for run artifacts - **[FR-12]** Generate signed URLs for secure Google Cloud Storage access - **[FR-13]** Provide user and organization information retrieval with sensitive data masking options +- **[FR-14]** Support external token providers to bypass internal OAuth 2.0 flows for machine-to-machine, service account, or custom token lifecycle scenarios. ### 1.3 Non-Functional Requirements @@ -44,7 +45,6 @@ The Platform Module shall: ### 1.4 Constraints and Limitations - OAuth 2.0 dependency: Requires external Auth0 service for authentication, creating external dependency -- Browser dependency: Interactive flow requires web browser availability, limiting headless deployment options - Network dependency: Requires internet connectivity for initial authentication and token validation - Platform-specific: Designed specifically for Aignostics Platform API integration @@ -58,6 +58,7 @@ The Platform Module shall: platform/ ├── _service.py # Core service implementation with health monitoring ├── _client.py # API client factory and configuration management +├── _api.py # Authenticated API wrapper (_AuthenticatedApi, _AuthenticatedResource) ├── _authentication.py # OAuth flows and token management ├── _cli.py # Command-line interface for user operations ├── _settings.py # Environment-specific configuration management @@ -90,7 +91,7 @@ platform/ - **Factory Pattern**: `Client.get_api_client()` creates configured API clients based on environment settings - **Service Layer Pattern**: Business logic encapsulated in service classes with clean separation from API details -- **Strategy Pattern**: Multiple authentication flows (Authorization Code vs Device Flow) selected based on environment capabilities +- **Strategy Pattern**: Multiple authentication flows (Authorization Code vs Device Flow) selected based on environment capabilities; external token provider as a fully independent alternative strategy - **Template Method Pattern**: Base authentication flow with specific implementations for different OAuth grant types --- @@ -109,10 +110,10 @@ platform/ ### 3.2 Outputs -| Output Type | Destination | Format/Type | Success Criteria | Code Location | -| ---------------- | ------------------- | ---------------------- | --------------------------------------------------- | ---------------------------------------------------- | -| JWT Access Token | Token cache/memory | String | Valid JWT with required claims and unexpired | `_authentication.py::get_token()` return value | -| API Client | Client applications | PublicApi object | Authenticated and configured for target environment | `_client.py::Client.get_api_client()` factory method | +| Output Type | Destination | Format/Type | Success Criteria | Code Location | +| ---------------- | ------------------- | ---------------------------- | --------------------------------------------------- | ---------------------------------------------------- | +| JWT Access Token | Token cache/memory | String | Valid JWT with required claims and unexpired | `_authentication.py::get_token()` return value | +| API Client | Client applications | `Client` object | Authenticated and configured for target environment | `_client.py::Client.__init__()` constructor | | User Information | CLI/Application | UserInfo/Me objects | Complete user and organization data | `_service.py::Service.get_user_info()` method | | Health Status | Monitoring systems | Health object | Accurate service and dependency status | `_service.py::Service.health()` method | | Downloaded Files | Local filesystem | Binary/structured data | Verified checksums and complete downloads | `_utils.py` download functions and `ApplicationRun` | @@ -202,7 +203,9 @@ ApplicationVersionDocument: ```mermaid graph TD - A[User Request] --> B{Token Cached?} + A[User Request] --> X{External Token Provider?} + X -->|Yes| I[Create API Client with External Provider] + X -->|No| B{Token Cached?} B -->|Yes| C[Use Cached Token] B -->|No| D[OAuth Authentication] @@ -233,7 +236,11 @@ graph TD class Client: """Main client for interacting with the Aignostics Platform API.""" - def __init__(self, cache_token: bool = True) -> None: + def __init__( + self, + cache_token: bool = True, + token_provider: Callable[[], str] | None = None, + ) -> None: """Initializes authenticated API client with resource accessors.""" def me(self) -> Me: @@ -246,7 +253,10 @@ class Client: """Creates ApplicationRun instance for existing run.""" @staticmethod - def get_api_client(cache_token: bool = True) -> PublicApi: + def get_api_client( + cache_token: bool = True, + token_provider: Callable[[], str] | None = None, + ) -> _AuthenticatedApi: # internal subclass of PublicApi; exposes token_provider attribute """Creates authenticated API client with proper configuration.""" ``` @@ -273,10 +283,10 @@ class Service(BaseService): ``` ```python -class Applications: +class Applications(_AuthenticatedResource): """Resource class for managing applications.""" - def __init__(self, api: PublicApi) -> None: + def __init__(self, api: _AuthenticatedApi) -> None: """Initializes the Applications resource with the API client.""" def list(self) -> Iterator[Application]: @@ -288,11 +298,11 @@ class Applications: ``` ```python -class Versions: +class Versions(_AuthenticatedResource): """Resource class for managing application versions.""" - def __init__(self, api: PublicApi) -> None: - """Initializes the Versions resource with the API client.""" + # Constructor inherited from _AuthenticatedResource + # def __init__(self, api: _AuthenticatedApi) -> None: def list(self, application: Application | str) -> Iterator[ApplicationVersion]: """Find all versions for a specific application.""" @@ -378,11 +388,11 @@ class Documents: ``` ```python -class Runs: +class Runs(_AuthenticatedResource): """Resource class for managing application runs.""" - def __init__(self, api: PublicApi) -> None: - """Initializes the Runs resource with the API client.""" + # Constructor inherited from _AuthenticatedResource + # def __init__(self, api: _AuthenticatedApi) -> None: def create(self, application_version: str, items: list[ItemCreationRequest]) -> ApplicationRun: """Creates a new application run.""" @@ -442,10 +452,10 @@ class Runs: ``` ```python -class ApplicationRun: +class ApplicationRun(_AuthenticatedResource): """Represents a single application run.""" - def __init__(self, api: PublicApi, application_run_id: str) -> None: + def __init__(self, api: _AuthenticatedApi, application_run_id: str) -> None: """Initializes an ApplicationRun instance.""" @classmethod diff --git a/src/aignostics/platform/CLAUDE.md b/src/aignostics/platform/CLAUDE.md index 6cb69dd7..0c124ad7 100644 --- a/src/aignostics/platform/CLAUDE.md +++ b/src/aignostics/platform/CLAUDE.md @@ -96,8 +96,8 @@ class Client: runs: Runs # Note: No separate 'versions' accessor - versions accessed via applications - def __init__(self, cache_token: bool = True): - self._api = Client.get_api_client(cache_token=cache_token) + def __init__(self, cache_token: bool = True, token_provider: Callable[[], str] | None = None): + self._api = Client.get_api_client(cache_token=cache_token, token_provider=token_provider) self.applications = Applications(self._api) self.runs = Runs(self._api) @@ -236,35 +236,35 @@ class Runs: ┌────────────────────────────────────────────────────┐ │ SDK Metadata System │ ├────────────────────────────────────────────────────┤ -│ Pydantic Models (Validation + Schema Generation) │ -│ ├─ RunSdkMetadata (run-level metadata) │ -│ │ ├─ SubmissionMetadata (how/when submitted) │ -│ │ ├─ UserMetadata (organization/user info) │ -│ │ ├─ CIMetadata (GitHub Actions + pytest) │ -│ │ ├─ WorkflowMetadata (control flags) │ -│ │ ├─ SchedulingMetadata (due dates/deadlines) │ -│ │ ├─ tags (set[str]) - NEW │ -│ │ ├─ created_at (timestamp) - NEW │ -│ │ └─ updated_at (timestamp) - NEW │ -│ └─ ItemSdkMetadata (item-level metadata) - NEW │ -│ ├─ PlatformBucketMetadata (storage info) │ -│ ├─ tags (set[str]) │ -│ ├─ created_at (timestamp) │ -│ └─ updated_at (timestamp) │ +│ Pydantic Models (Validation + Schema Generation) │ +│ ├─ RunSdkMetadata (run-level metadata) │ +│ │ ├─ SubmissionMetadata (how/when submitted) │ +│ │ ├─ UserMetadata (organization/user info) │ +│ │ ├─ CIMetadata (GitHub Actions + pytest) │ +│ │ ├─ WorkflowMetadata (control flags) │ +│ │ ├─ SchedulingMetadata (due dates/deadlines) │ +│ │ ├─ tags (set[str]) - NEW │ +│ │ ├─ created_at (timestamp) - NEW │ +│ │ └─ updated_at (timestamp) - NEW │ +│ └─ ItemSdkMetadata (item-level metadata) - NEW │ +│ ├─ PlatformBucketMetadata (storage info) │ +│ ├─ tags (set[str]) │ +│ ├─ created_at (timestamp) │ +│ └─ updated_at (timestamp) │ ├────────────────────────────────────────────────────┤ │ Runtime Functions │ -│ ├─ build_run_sdk_metadata() → dict │ -│ ├─ validate_run_sdk_metadata() → bool │ -│ ├─ get_run_sdk_metadata_json_schema() → dict │ -│ ├─ build_item_sdk_metadata() → dict - NEW │ -│ ├─ validate_item_sdk_metadata() → bool - NEW │ -│ └─ get_item_sdk_metadata_json_schema() → dict │ +│ ├─ build_run_sdk_metadata() → dict │ +│ ├─ validate_run_sdk_metadata() → bool │ +│ ├─ get_run_sdk_metadata_json_schema() → dict │ +│ ├─ build_item_sdk_metadata() → dict - NEW │ +│ ├─ validate_item_sdk_metadata() → bool - NEW │ +│ └─ get_item_sdk_metadata_json_schema() → dict │ ├────────────────────────────────────────────────────┤ │ JSON Schema (Versioned) │ -│ ├─ Run schema version: 0.0.4 │ -│ └─ Item schema version: 0.0.3 │ +│ ├─ Run schema version: 0.0.4 │ +│ └─ Item schema version: 0.0.3 │ │ Published at: docs/source/_static/ │ -│ URLs: sdk_{run|item}_custom_metadata_schema_* │ +│ URLs: sdk_{run|item}_custom_metadata_schema_* │ └────────────────────────────────────────────────────┘ ``` @@ -633,24 +633,24 @@ Comprehensive test suite in `tests/aignostics/platform/sdk_metadata_test.py`: ``` ┌────────────────────────────────────────────────────┐ -│ Operation Caching System │ +│ Operation Caching System │ ├────────────────────────────────────────────────────┤ │ Cache Storage: dict[cache_key, (result, expiry)] │ -│ ├─ Token-aware caching (per-user isolation) │ +│ ├─ Token-aware caching (per-user isolation) │ │ ├─ TTL-based expiration │ │ └─ Automatic invalidation on mutations │ ├────────────────────────────────────────────────────┤ │ Decorator: @cached_operation │ -│ ├─ ttl: Time-to-live in seconds │ -│ ├─ use_token: Include auth token in key │ -│ └─ instance_attrs: Per-instance caching │ +│ ├─ ttl: Time-to-live in seconds │ +│ ├─ token_provider: Callable for per-user key │ +│ └─ instance_attrs: Per-instance caching │ ├────────────────────────────────────────────────────┤ │ Cache Key Generation │ -│ ├─ cache_key(): func_name:args:kwargs │ -│ └─ cache_key_with_token(): token_hash:... │ +│ ├─ cache_key(): func_name:args:kwargs │ +│ └─ token_hash prefix when token_provider set │ ├────────────────────────────────────────────────────┤ │ Cache Invalidation │ -│ └─ operation_cache_clear(): Clear on mutations │ +│ └─ operation_cache_clear(): Clear on mutations │ └────────────────────────────────────────────────────┘ ``` @@ -663,12 +663,16 @@ Comprehensive test suite in `tests/aignostics/platform/sdk_metadata_test.py`: _operation_cache: dict[str, tuple[Any, float]] = {} -def cached_operation(ttl: int, *, use_token: bool = True, instance_attrs: tuple[str, ...] | None = None) -> Callable: +def cached_operation( + ttl: int, *, token_provider: Callable[[], str] | None = None, instance_attrs: tuple[str, ...] | None = None +) -> Callable: """Decorator for caching function results with TTL. Args: ttl: Time-to-live for cache in seconds - use_token: Include authentication token in cache key for per-user isolation + token_provider: Callable that returns the current access token; when provided, + its result is hashed into the cache key for per-user isolation. + Pass None (default) for anonymous / token-independent caching. instance_attrs: Instance attributes to include in key (e.g., 'run_id') Behavior: @@ -679,12 +683,13 @@ def cached_operation(ttl: int, *, use_token: bool = True, instance_attrs: tuple[ """ def decorator(func): + @functools.wraps(func) def wrapper(*args, **kwargs): # Build cache key func_qualified_name = func.__qualname__ # e.g., "Client.me" - if use_token: - token_hash = hashlib.sha256(get_token().encode()).hexdigest()[:16] + if token_provider is not None: + token_hash = hashlib.sha256(token_provider().encode()).hexdigest()[:16] key = f"{token_hash}:{func_qualified_name}:{args}:{sorted(kwargs.items())}" else: key = f"{func_qualified_name}:{args}:{sorted(kwargs.items())}" @@ -757,13 +762,13 @@ auth_jwk_set_cache_ttl: int = 86400 # 1 day ```python # From _client.py -@cached_operation(ttl=settings().me_cache_ttl, use_token=True) +@cached_operation(ttl=settings().me_cache_ttl, token_provider=self._api.token_provider) def me_with_retry() -> Me: return Retrying(...)(lambda: self._api.get_me_v1_me_get(...)) # From resources/runs.py -@cached_operation(ttl=settings().run_cache_ttl, use_token=True) +@cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) def details_with_retry(run_id: str) -> RunData: return Retrying(...)(lambda: self._api.get_run_v1_runs_run_id_get(run_id, ...)) ``` @@ -796,10 +801,10 @@ def delete(self) -> None: **Key Design Decisions:** 1. **Global Cache Clearing**: All caches are cleared on ANY mutation to ensure consistency -2. **Token-Aware**: Caching is per-user by default (use_token=True), preventing data leakage +2. **Token-Aware**: Caching is per-user when `token_provider` is supplied (the default for all resource classes), preventing data leakage between users 3. **No Partial Invalidation**: Simplicity over optimization - clear everything on write 4. **TTL-Based Expiration**: Stale data automatically expires after configured TTL -5. **Token Changes**: Cache keys include token hash, so token refresh creates new cache namespace +5. **Token Changes**: Cache keys include a hash of the token, so token refresh creates a new cache namespace automatically **Operations That Are Cached:** @@ -878,26 +883,26 @@ Comprehensive test suite in `tests/aignostics/platform/client_cache_test.py`: ``` ┌────────────────────────────────────────────────────┐ -│ Retry and Timeout System (Tenacity) │ +│ Retry and Timeout System (Tenacity) │ ├────────────────────────────────────────────────────┤ │ Retry Policy │ -│ ├─ Exponential backoff with jitter │ -│ ├─ Configurable max attempts (default: 4) │ -│ ├─ Configurable wait times (0.1s - 60s) │ -│ └─ Logs warnings before sleep │ +│ ├─ Exponential backoff with jitter │ +│ ├─ Configurable max attempts (default: 4) │ +│ ├─ Configurable wait times (0.1s - 60s) │ +│ └─ Logs warnings before sleep │ ├────────────────────────────────────────────────────┤ │ Retryable Exceptions │ -│ ├─ ServiceException (5xx errors) │ -│ ├─ Urllib3TimeoutError │ -│ ├─ PoolError │ -│ ├─ IncompleteRead │ -│ ├─ ProtocolError │ -│ └─ ProxyError │ +│ ├─ ServiceException (5xx errors) │ +│ ├─ Urllib3TimeoutError │ +│ ├─ PoolError │ +│ ├─ IncompleteRead │ +│ ├─ ProtocolError │ +│ └─ ProxyError │ ├────────────────────────────────────────────────────┤ │ Timeout Configuration │ -│ ├─ Per-operation timeouts (default: 30s) │ -│ ├─ Range: 0.1s - 300s │ -│ └─ Separate timeouts for mutating ops │ +│ ├─ Per-operation timeouts (default: 30s) │ +│ ├─ Range: 0.1s - 300s │ +│ └─ Separate timeouts for mutating ops │ └────────────────────────────────────────────────────┘ ``` @@ -919,7 +924,7 @@ RETRYABLE_EXCEPTIONS = ( ```python # Standard retry pattern used throughout the codebase -@cached_operation(ttl=settings().me_cache_ttl, use_token=True) +@cached_operation(ttl=settings().me_cache_ttl, token_provider=self._api.token_provider) def me_with_retry() -> Me: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -1288,9 +1293,17 @@ Updated test suite in `tests/aignostics/platform/e2e_test.py`: ```python from aignostics.platform import Client -# Initialize with automatic authentication +# Initialize with automatic authentication (internal OAuth) client = Client(cache_token=True) + +# Initialize with an external token provider (e.g. machine-to-machine) +def my_token_provider() -> str: + return fetch_token_from_my_system() + + +client = Client(token_provider=my_token_provider) + # Get user info me = client.me() print(f"User: {me.email}, Organization: {me.organization.name}") diff --git a/src/aignostics/platform/_api.py b/src/aignostics/platform/_api.py new file mode 100644 index 00000000..ab250a55 --- /dev/null +++ b/src/aignostics/platform/_api.py @@ -0,0 +1,120 @@ +"""Authenticated API wrapper and configuration. + +This module defines the thin API subclass and configuration that lift +``token_provider`` to a first-class attribute. Kept separate from ``_client`` +so that resource modules can import these types directly without circular +dependencies. + +Shared retry helpers (``RETRYABLE_EXCEPTIONS``, ``_log_retry_attempt``) live +here so every platform sub-module can import from a single source of truth. +""" + +from collections.abc import Callable + +from aignx.codegen.api.public_api import PublicApi +from aignx.codegen.api_client import ApiClient +from aignx.codegen.configuration import AuthSettings, Configuration +from aignx.codegen.exceptions import ServiceException +from loguru import logger +from tenacity import RetryCallState +from urllib3.exceptions import IncompleteRead, PoolError, ProtocolError, ProxyError +from urllib3.exceptions import TimeoutError as Urllib3TimeoutError + +RETRYABLE_EXCEPTIONS = ( + ServiceException, + Urllib3TimeoutError, + PoolError, + IncompleteRead, + ProtocolError, + ProxyError, +) + + +def _log_retry_attempt(retry_state: RetryCallState) -> None: + """Log a retry attempt with function name, sleep duration, and exception.""" + fn = retry_state.fn + fn_module = fn.__module__ if fn and hasattr(fn, "__module__") else "" + fn_name = fn.__name__ if fn and hasattr(fn, "__name__") else "" + logger.warning( + "Retrying {}.{} in {} seconds as attempt {} ended with: {}", + fn_module, + fn_name, + retry_state.next_action.sleep if retry_state.next_action else 0, + retry_state.attempt_number, + retry_state.outcome.exception() if retry_state.outcome else "", + ) + + +class _OAuth2TokenProviderConfiguration(Configuration): + """Overwrites the original Configuration to call a function to obtain a bearer token. + + The base class does not support callbacks. This is necessary for integrations where + access tokens may expire or need to be refreshed or rotated automatically. + """ + + def __init__( + self, host: str, ssl_ca_cert: str | None = None, token_provider: Callable[[], str] | None = None + ) -> None: + super().__init__(host=host, ssl_ca_cert=ssl_ca_cert) + self.token_provider = token_provider + + def auth_settings(self) -> AuthSettings: + token = self.token_provider() if self.token_provider else None + if not token: + if self.token_provider is not None: + logger.warning( + "token_provider returned an empty or None token; " + "request will proceed without an Authorization header" + ) + return {} + return { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "in": "header", + "key": "Authorization", + "value": f"Bearer {token}", + } + } + + +class _AuthenticatedApi(PublicApi): + """Thin wrapper around the generated :class:`PublicApi`. + + Lifts ``token_provider`` from the deeply-nested ``Configuration`` to a + top-level attribute, making it accessible without traversing codegen internals. + """ + + token_provider: Callable[[], str] | None + + def __init__(self, api_client: ApiClient, token_provider: Callable[[], str] | None = None) -> None: + super().__init__(api_client) + self.token_provider = token_provider + + +class _AuthenticatedResource: + """Base for platform resource classes that require an authenticated API client. + + Validates at construction time that the provided API object is a genuine + :class:`_AuthenticatedApi` instance, ensuring ``token_provider`` is available + for per-user cache key isolation in ``@cached_operation``. + """ + + _api: _AuthenticatedApi + + def __init__(self, api: _AuthenticatedApi) -> None: + """Initialize with an authenticated API client. + + Args: + api: The configured API client providing ``token_provider``. + + Raises: + TypeError: If *api* is not an :class:`_AuthenticatedApi` instance. + """ + if not isinstance(api, _AuthenticatedApi): # runtime guard for untyped callers + msg = ( # type: ignore[unreachable] + f"{type(self).__name__} requires _AuthenticatedApi, " + f"got {type(api).__name__!r}. " + "Use Client to obtain a correctly configured instance." + ) + raise TypeError(msg) + self._api = api diff --git a/src/aignostics/platform/_authentication.py b/src/aignostics/platform/_authentication.py index 3b8b101c..90e69581 100644 --- a/src/aignostics/platform/_authentication.py +++ b/src/aignostics/platform/_authentication.py @@ -17,13 +17,13 @@ from requests.exceptions import HTTPError, JSONDecodeError, RequestException from requests_oauthlib import OAuth2Session from tenacity import ( - RetryCallState, Retrying, retry_if_exception, stop_after_attempt, wait_exponential_jitter, ) +from aignostics.platform._api import _log_retry_attempt from aignostics.platform._messages import ( AUTHENTICATION_FAILED, AUTHENTICATION_FAILED_ACCESS_TOKEN_FROM_REFRESH_TOKEN, @@ -32,26 +32,6 @@ ) from aignostics.platform._settings import settings - -def _log_retry_attempt(retry_state: RetryCallState) -> None: - """Custom callback for logging retry attempts with loguru. - - Args: - retry_state: The retry state from tenacity. - """ - fn = retry_state.fn - fn_module = fn.__module__ if fn and hasattr(fn, "__module__") else "" - fn_name = fn.__name__ if fn and hasattr(fn, "__name__") else "" - logger.warning( - "Retrying {}.{} in {} seconds as attempt {} ended with: {}", - fn_module, - fn_name, - retry_state.next_action.sleep if retry_state.next_action else 0, - retry_state.attempt_number, - retry_state.outcome.exception() if retry_state.outcome else "", - ) - - CALLBACK_PORT_RETRY_COUNT = 20 CALLBACK_PORT_BACKOFF_DELAY = 1 JWK_CLIENT_CACHE_SIZE = 4 # Multiple entries exist in the rare case of settings changing at runtime only diff --git a/src/aignostics/platform/_client.py b/src/aignostics/platform/_client.py index 84167ae4..3e2de9df 100644 --- a/src/aignostics/platform/_client.py +++ b/src/aignostics/platform/_client.py @@ -4,24 +4,25 @@ from urllib.request import getproxies import semver -from aignx.codegen.api.public_api import PublicApi from aignx.codegen.api_client import ApiClient -from aignx.codegen.configuration import AuthSettings, Configuration -from aignx.codegen.exceptions import NotFoundException, ServiceException +from aignx.codegen.exceptions import NotFoundException from aignx.codegen.models import ApplicationReadResponse as Application from aignx.codegen.models import MeReadResponse as Me from aignx.codegen.models import VersionReadResponse as ApplicationVersion from loguru import logger from tenacity import ( - RetryCallState, Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter, ) -from urllib3.exceptions import IncompleteRead, PoolError, ProtocolError, ProxyError -from urllib3.exceptions import TimeoutError as Urllib3TimeoutError +from aignostics.platform._api import ( + RETRYABLE_EXCEPTIONS, + _AuthenticatedApi, + _log_retry_attempt, + _OAuth2TokenProviderConfiguration, +) from aignostics.platform._authentication import get_token from aignostics.platform._operation_cache import cached_operation from aignostics.platform.resources.applications import Applications, Versions @@ -30,61 +31,9 @@ from ._settings import settings -RETRYABLE_EXCEPTIONS = ( - ServiceException, - Urllib3TimeoutError, - PoolError, - IncompleteRead, - ProtocolError, - ProxyError, -) - - -def _log_retry_attempt(retry_state: RetryCallState) -> None: - """Custom callback for logging retry attempts with loguru. - - Args: - retry_state: The retry state from tenacity. - """ - fn = retry_state.fn - fn_module = fn.__module__ if fn and hasattr(fn, "__module__") else "" - fn_name = fn.__name__ if fn and hasattr(fn, "__name__") else "" - logger.warning( - "Retrying {}.{} in {} seconds as attempt {} ended with: {}", - fn_module, - fn_name, - retry_state.next_action.sleep if retry_state.next_action else 0, - retry_state.attempt_number, - retry_state.outcome.exception() if retry_state.outcome else "", - ) - - -class _OAuth2TokenProviderConfiguration(Configuration): - """ - Overwrites the original Configuration to call a function to obtain a refresh token. - - The base class does not support callbacks. This is necessary for integrations where - tokens may expire or need to be refreshed automatically. - """ - - def __init__( - self, host: str, ssl_ca_cert: str | None = None, token_provider: Callable[[], str] | None = None - ) -> None: - super().__init__(host=host, ssl_ca_cert=ssl_ca_cert) - self.token_provider = token_provider - - def auth_settings(self) -> AuthSettings: - token = self.token_provider() if self.token_provider else None - if not token: - return {} - return { - "OAuth2AuthorizationCodeBearer": { - "type": "oauth2", - "in": "header", - "key": "Authorization", - "value": f"Bearer {token}", - } - } +# Safety bound for the external token-provider cache. In normal usage callers +# reuse a single provider reference, so this limit should never be reached. +_MAX_EXTERNAL_CLIENTS = 16 class Client: @@ -92,29 +41,37 @@ class Client: - Provides access to platform resources like applications, versions, and runs. - Handles authentication and API client configuration. + - Supports external token providers for machine-to-machine or custom auth flows. - Retries on network and server errors for specific operations. - Caches operation results for specific operations. """ - _api_client_cached: ClassVar[PublicApi | None] = None - _api_client_uncached: ClassVar[PublicApi | None] = None + _api_client_cached: ClassVar[_AuthenticatedApi | None] = None + _api_client_uncached: ClassVar[_AuthenticatedApi | None] = None + _api_client_external: ClassVar[dict[Callable[[], str], _AuthenticatedApi]] = {} + _api: _AuthenticatedApi applications: Applications versions: Versions runs: Runs - def __init__(self, cache_token: bool = True) -> None: + def __init__(self, cache_token: bool = True, token_provider: Callable[[], str] | None = None) -> None: """Initializes a client instance with authenticated API access. Args: - cache_token (bool): If True, caches the authentication token. - Defaults to True. + cache_token: If True, caches the authentication token. Defaults to True. + Ignored when ``token_provider`` is supplied. + token_provider: Optional external token provider callable. When provided, + bypasses internal OAuth authentication entirely. The callable must + return a raw access token string (without the ``Bearer `` prefix). + When set, ``cache_token`` has no effect because the external provider + manages its own token lifecycle. Sets up resource accessors for applications, versions, and runs. """ try: - logger.trace("Initializing client with cache_token={}", cache_token) - self._api = Client.get_api_client(cache_token=cache_token) + logger.trace("Initializing client with cache_token={}, token_provider={}", cache_token, token_provider) + self._api = Client.get_api_client(cache_token=cache_token, token_provider=token_provider) self.applications: Applications = Applications(self._api) self.runs: Runs = Runs(self._api) self.versions: Versions = Versions(self._api) @@ -143,7 +100,7 @@ def me(self, nocache: bool = False) -> Me: aignx.codegen.exceptions.ApiException: If the API call fails. """ - @cached_operation(ttl=settings().me_cache_ttl, use_token=True) + @cached_operation(ttl=settings().me_cache_ttl, token_provider=self._api.token_provider) def me_with_retry() -> Me: return Retrying( # We are not using Tenacity annotations as settings can change at runtime retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -177,7 +134,7 @@ def application(self, application_id: str, nocache: bool = False) -> Application aignx.codegen.exceptions.ApiException: If the API call fails. """ - @cached_operation(ttl=settings().application_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_cache_ttl, token_provider=self._api.token_provider) def application_with_retry(application_id: str) -> Application: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -234,7 +191,7 @@ def application_version( raise ValueError(message) # Make the API call with retry logic and caching - @cached_operation(ttl=settings().application_version_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_version_cache_ttl, token_provider=self._api.token_provider) def application_version_with_retry(application_id: str, version: str) -> ApplicationVersion: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -268,44 +225,64 @@ def run(self, run_id: str) -> Run: return Run(self._api, run_id) @staticmethod - def get_api_client(cache_token: bool = True) -> PublicApi: + def get_api_client(cache_token: bool = True, token_provider: Callable[[], str] | None = None) -> _AuthenticatedApi: """Create and configure an authenticated API client. API client instances are shared across all Client instances for efficient connection reuse. - Two separate instances are maintained: one for cached tokens and one for uncached tokens. + Three pools are maintained: cached-token, uncached-token, and external-provider (keyed by + the provider callable — callers should reuse a stable ``token_provider`` reference for + connection reuse). Args: - cache_token (bool): If True, caches the authentication token. - Defaults to True. + cache_token: If True, caches the authentication token. Defaults to True. + token_provider: Optional external token provider. When provided, bypasses + internal OAuth and uses this callable to obtain bearer tokens. Returns: - PublicApi: Configured API client with authentication token. + _AuthenticatedApi: Configured API client with authentication token. Raises: RuntimeError: If authentication fails. """ - # Return cached instance if available - if cache_token and Client._api_client_cached is not None: + # Check singleton caches first + if token_provider is not None: + if token_provider in Client._api_client_external: + return Client._api_client_external[token_provider] + elif cache_token and Client._api_client_cached is not None: return Client._api_client_cached - if not cache_token and Client._api_client_uncached is not None: + elif not cache_token and Client._api_client_uncached is not None: return Client._api_client_uncached - def token_provider() -> str: - return get_token(use_cache=cache_token) + # Resolve the effective token provider + effective_provider: Callable[[], str] = ( + token_provider if token_provider is not None else (lambda: get_token(use_cache=cache_token)) + ) + # Build the API client ca_file = os.getenv("REQUESTS_CA_BUNDLE") # point to .cer file of proxy if defined config = _OAuth2TokenProviderConfiguration( - host=settings().api_root, ssl_ca_cert=ca_file, token_provider=token_provider + host=settings().api_root, ssl_ca_cert=ca_file, token_provider=effective_provider ) config.proxy = getproxies().get("https") # use system proxy - client = ApiClient( - config, - ) + client = ApiClient(config) client.user_agent = user_agent() - api_client = PublicApi(client) - - # Cache the instance - if cache_token: + api_client = _AuthenticatedApi(client, effective_provider) + + # Store in the appropriate singleton cache. + # For external providers we use a simple bounded dict rather than LRU: + # switching providers is rare in practice, and a full clear is simpler + # than tracking access order while still bounding memory. + if token_provider is not None: + if len(Client._api_client_external) >= _MAX_EXTERNAL_CLIENTS: + logger.warning( + "External token provider cache exceeded {} entries; clearing to prevent resource leak. " + "Pass a stable (module-level or instance) callable — each new lambda is a distinct " + "key and will re-trigger this eviction.", + _MAX_EXTERNAL_CLIENTS, + ) + Client._api_client_external.clear() + Client._api_client_external[token_provider] = api_client + elif cache_token: Client._api_client_cached = api_client else: Client._api_client_uncached = api_client diff --git a/src/aignostics/platform/_operation_cache.py b/src/aignostics/platform/_operation_cache.py index 90b9cc8d..e855f22b 100644 --- a/src/aignostics/platform/_operation_cache.py +++ b/src/aignostics/platform/_operation_cache.py @@ -11,13 +11,16 @@ - Supports selective cache clearing by function """ +from __future__ import annotations + +import functools import hashlib import time import typing as t -from collections.abc import Callable -from typing import Any, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar -from ._authentication import get_token +if TYPE_CHECKING: + from collections.abc import Callable # Cache storage for operation results _operation_cache: dict[str, tuple[Any, float]] = {} @@ -92,16 +95,19 @@ def cache_key_with_token(token: str, func_qualified_name: str, *args: object, ** def cached_operation( - ttl: int, *, use_token: bool = True, instance_attrs: tuple[str, ...] | None = None + ttl: int, + *, + token_provider: Callable[[], str] | None = None, + instance_attrs: tuple[str, ...] | None = None, ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Caches the result of a function call for a specified time-to-live (TTL). Args: ttl (int): Time-to-live for the cache in seconds. - use_token (bool): If True, includes the authentication token in the cache key. - This is useful for Client methods that should cache per-user. - When use_token is True and no instance_attrs are specified, the 'self' - argument is excluded from the cache key to enable cache sharing across instances. + token_provider (Callable[[], str] | None): A callable returning the current + authentication token string. When provided, the token is included in the + cache key for per-user isolation. Pass ``None`` to omit the token from + the cache key. instance_attrs (tuple[str, ...] | None): Instance attributes to include in the cache key. This is useful for instance methods where caching should be per-instance based on specific attributes (e.g., 'run_id' for Run.details()). @@ -116,6 +122,7 @@ def cached_operation( """ def decorator(func: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: # Check if nocache is requested and remove it from kwargs before passing to func nocache = kwargs.pop("nocache", False) @@ -132,8 +139,9 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: instance_values = tuple(getattr(instance, attr) for attr in instance_attrs) cache_args = instance_values + args[1:] - if use_token: - key = cache_key_with_token(get_token(True), func_qualified_name, *cache_args, **kwargs) + if token_provider is not None: + token = token_provider() + key = cache_key_with_token(token, func_qualified_name, *cache_args, **kwargs) else: key = cache_key(func_qualified_name, *cache_args, **kwargs) diff --git a/src/aignostics/platform/resources/applications.py b/src/aignostics/platform/resources/applications.py index df2fd479..827147d1 100644 --- a/src/aignostics/platform/resources/applications.py +++ b/src/aignostics/platform/resources/applications.py @@ -16,25 +16,26 @@ import requests import semver -from aignx.codegen.api.public_api import PublicApi from aignx.codegen.exceptions import NotFoundException, ServiceException from aignx.codegen.models import ApplicationReadResponse as Application from aignx.codegen.models import ApplicationReadShortResponse as ApplicationSummary from aignx.codegen.models import ApplicationVersion as VersionTuple from aignx.codegen.models import VersionDocumentResponse as VersionDocumentData from aignx.codegen.models import VersionReadResponse as ApplicationVersion -from loguru import logger from pydantic import BaseModel, ConfigDict from tenacity import ( - RetryCallState, Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter, ) -from urllib3.exceptions import IncompleteRead, PoolError, ProtocolError, ProxyError -from urllib3.exceptions import TimeoutError as Urllib3TimeoutError +from aignostics.platform._api import ( + RETRYABLE_EXCEPTIONS, + _AuthenticatedApi, + _AuthenticatedResource, + _log_retry_attempt, +) from aignostics.platform._authentication import get_token from aignostics.platform._operation_cache import cached_operation from aignostics.platform._settings import settings @@ -43,49 +44,13 @@ _DOCUMENT_DOWNLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB -RETRYABLE_EXCEPTIONS = ( - ServiceException, - Urllib3TimeoutError, - PoolError, - IncompleteRead, - ProtocolError, - ProxyError, -) - - -def _log_retry_attempt(retry_state: RetryCallState) -> None: - """Custom callback for logging retry attempts with loguru. - Args: - retry_state: The retry state from tenacity. - """ - fn = retry_state.fn - fn_module = fn.__module__ if fn and hasattr(fn, "__module__") else "" - fn_name = fn.__name__ if fn and hasattr(fn, "__name__") else "" - logger.warning( - "Retrying {}.{} in {} seconds as attempt {} ended with: {}", - fn_module, - fn_name, - retry_state.next_action.sleep if retry_state.next_action else 0, - retry_state.attempt_number, - retry_state.outcome.exception() if retry_state.outcome else "", - ) - - -class Versions: +class Versions(_AuthenticatedResource): """Resource class for managing application versions. Provides operations to list and retrieve application versions. """ - def __init__(self, api: PublicApi) -> None: - """Initializes the Versions resource with the API platform. - - Args: - api (PublicApi): The configured API platform. - """ - self._api = api - def _get_application_version_validated( self, application_id: str, application_version: VersionTuple | str | None ) -> str: @@ -134,7 +99,7 @@ def list(self, application: Application | str, nocache: bool = False) -> builtin """ application_id = application.application_id if isinstance(application, Application) else application - @cached_operation(ttl=settings().application_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_cache_ttl, token_provider=self._api.token_provider) def list_with_retry(app_id: str) -> Application: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -180,7 +145,7 @@ def details( application_version = self._get_application_version_validated(application_id, application_version) # Make the API call with retry logic and caching - @cached_operation(ttl=settings().application_version_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_version_cache_ttl, token_provider=self._api.token_provider) def details_with_retry(app_id: str, app_version: str) -> ApplicationVersion: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -317,11 +282,11 @@ class Documents: integrity is bounded by HTTPS transport and the signed-URL lifetime. """ - def __init__(self, api: PublicApi, application_id: str, application_version: str | VersionTuple) -> None: + def __init__(self, api: _AuthenticatedApi, application_id: str, application_version: str | VersionTuple) -> None: """Initializes the Documents resource bound to an application version. Args: - api (PublicApi): The configured API client. + api (_AuthenticatedApi): The configured API client. application_id (str): The ID of the application (e.g. "heta"). application_version (str | VersionTuple): The semantic version number (e.g. "1.0.0") or a VersionTuple. """ @@ -349,7 +314,7 @@ def list(self, nocache: bool = False) -> builtins.list[ApplicationVersionDocumen aignx.codegen.exceptions.ApiException: If the API request fails. """ - @cached_operation(ttl=settings().application_version_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_version_cache_ttl, token_provider=self._api.token_provider) def list_with_retry(application_id: str, application_version: str) -> builtins.list[VersionDocumentData]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -390,7 +355,7 @@ def details(self, document_name: str, nocache: bool = False) -> ApplicationVersi aignx.codegen.exceptions.ApiException: If the API request fails. """ - @cached_operation(ttl=settings().application_version_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_version_cache_ttl, token_provider=self._api.token_provider) def details_with_retry( application_id: str, application_version: str, document_name: str ) -> VersionDocumentData: @@ -658,19 +623,19 @@ def _stream_to_buffer() -> bytes: )(_stream_to_buffer) -class Applications: +class Applications(_AuthenticatedResource): """Resource class for managing applications. Provides operations to list applications and access version resources. """ - def __init__(self, api: PublicApi) -> None: + def __init__(self, api: _AuthenticatedApi) -> None: """Initializes the Applications resource with the API platform. Args: - api (PublicApi): The configured API platform. + api (_AuthenticatedApi): The configured API platform. """ - self._api = api + super().__init__(api) self.versions: Versions = Versions(self._api) def details(self, application_id: str, nocache: bool = False) -> Application: @@ -691,7 +656,7 @@ def details(self, application_id: str, nocache: bool = False) -> Application: aignx.codegen.exceptions.ApiException: If the API call fails. """ - @cached_operation(ttl=settings().application_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_cache_ttl, token_provider=self._api.token_provider) def details_with_retry(application_id: str) -> Application: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -716,10 +681,12 @@ def list(self, nocache: bool = False) -> t.Iterator[ApplicationSummary]: Retries on network and server errors for each page. + Args: + nocache (bool): If True, skip reading from cache and fetch fresh data from the API. + The fresh result will still be cached for subsequent calls. Defaults to False. + Returns: Iterator[ApplicationSummary]: An iterator over the available applications. - notcache (bool): If True, skip reading from cache and fetch fresh data from the API. - The fresh result will still be cached for subsequent calls. Defaults to False. Raises: aignx.codegen.exceptions.ApiException: If the API request fails. @@ -727,7 +694,7 @@ def list(self, nocache: bool = False) -> t.Iterator[ApplicationSummary]: # Create a wrapper function that applies retry logic and caching to each API call # Caching at this level ensures having a fresh iterator on cache hits - @cached_operation(ttl=settings().application_cache_ttl, use_token=True) + @cached_operation(ttl=settings().application_cache_ttl, token_provider=self._api.token_provider) def list_with_retry(**kwargs: object) -> builtins.list[ApplicationSummary]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index ecb1a3fa..d476ddb4 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -14,7 +14,6 @@ from typing import Any, cast import requests -from aignx.codegen.api.public_api import PublicApi from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException from aignx.codegen.models import ( ArtifactOutput, @@ -42,16 +41,19 @@ from loguru import logger from sentry_sdk import metrics from tenacity import ( - RetryCallState, Retrying, retry_if_exception_type, stop_after_attempt, stop_after_delay, wait_exponential_jitter, ) -from urllib3.exceptions import IncompleteRead, PoolError, ProtocolError, ProxyError -from urllib3.exceptions import TimeoutError as Urllib3TimeoutError +from aignostics.platform._api import ( + RETRYABLE_EXCEPTIONS, + _AuthenticatedApi, + _AuthenticatedResource, + _log_retry_attempt, +) from aignostics.platform._authentication import get_token from aignostics.platform._operation_cache import cached_operation, operation_cache_clear from aignostics.platform._sdk_metadata import ( @@ -72,35 +74,6 @@ from aignostics.platform.resources.utils import paginate from aignostics.utils import user_agent -RETRYABLE_EXCEPTIONS = ( - ServiceException, # TODO(Helmut): Do we want this down the road? - Urllib3TimeoutError, - PoolError, - IncompleteRead, - ProtocolError, - ProxyError, -) - - -def _log_retry_attempt(retry_state: RetryCallState) -> None: - """Custom callback for logging retry attempts with loguru. - - Args: - retry_state: The retry state from tenacity. - """ - fn = retry_state.fn - fn_module = fn.__module__ if fn and hasattr(fn, "__module__") else "" - fn_name = fn.__name__ if fn and hasattr(fn, "__name__") else "" - logger.warning( - "Retrying {}.{} in {} seconds as attempt {} ended with: {}", - fn_module, - fn_name, - retry_state.next_action.sleep if retry_state.next_action else 0, - retry_state.attempt_number, - retry_state.outcome.exception() if retry_state.outcome else "", - ) - - LIST_APPLICATION_RUNS_MAX_PAGE_SIZE = 100 LIST_APPLICATION_RUNS_MIN_PAGE_SIZE = 5 @@ -118,22 +91,22 @@ class DownloadTimeoutError(RuntimeError): }) -class Artifact: +class Artifact(_AuthenticatedResource): """Represents a single output artifact belonging to a run. Provides operations to resolve a fresh presigned download URL via the ``GET /api/v1/runs/{run_id}/artifacts/{artifact_id}/file`` endpoint. """ - def __init__(self, api: PublicApi, run_id: str, artifact_id: str) -> None: + def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str) -> None: """Initializes an Artifact instance. Args: - api (PublicApi): The configured API client. + api (_AuthenticatedApi): The configured API client. run_id (str): The ID of the parent run. artifact_id (str): The ID of the output artifact. """ - self._api = api + super().__init__(api) self.run_id = run_id self.artifact_id = artifact_id @@ -262,20 +235,20 @@ def _fetch_redirect_url( ) from e -class Run: +class Run(_AuthenticatedResource): """Represents a single application run. Provides operations to check status, retrieve results, and download artifacts. """ - def __init__(self, api: PublicApi, run_id: str) -> None: - """Initializes an Run instance. + def __init__(self, api: _AuthenticatedApi, run_id: str) -> None: + """Initializes a Run instance. Args: - api (PublicApi): The configured API client. + api (_AuthenticatedApi): The configured API client. run_id (str): The ID of the application run. """ - self._api = api + super().__init__(api) self.run_id = run_id @classmethod @@ -313,7 +286,7 @@ def details(self, nocache: bool = False, hide_platform_queue_position: bool = Fa Exception: If the API request fails. """ - @cached_operation(ttl=settings().run_cache_ttl, use_token=True) + @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) def details_with_retry(run_id: str) -> RunData: def _fetch() -> RunData: return Retrying( @@ -400,7 +373,7 @@ def results( # Create a wrapper function that applies retry logic and caching to each API call # Caching at this level ensures having a fresh iterator on cache hits - @cached_operation(ttl=settings().run_cache_ttl, use_token=True) + @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -696,22 +669,22 @@ def __str__(self) -> str: ) -class Runs: +class Runs(_AuthenticatedResource): """Resource class for managing application runs. Provides operations to submit, find, and retrieve runs. """ - def __init__(self, api: PublicApi) -> None: + def __init__(self, api: _AuthenticatedApi) -> None: """Initializes the Runs resource with the API client. Args: - api (PublicApi): The configured API client. + api (_AuthenticatedApi): The configured API client. """ - self._api = api + super().__init__(api) def __call__(self, run_id: str) -> Run: - """Retrieves an Run instance for an existing run. + """Retrieves a Run instance for an existing run. Args: run_id (str): The ID of the application run. @@ -878,7 +851,7 @@ def list_data( # noqa: PLR0913, PLR0917 ) raise ValueError(message) - @cached_operation(ttl=settings().run_cache_ttl, use_token=True) + @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) def list_data_with_retry(**kwargs: object) -> builtins.list[RunData]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 28671793..393547d6 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -361,7 +361,7 @@ class TestNocacheDecoratorBehavior: """Verify default behavior uses cache.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60, token_provider=None) def test_func() -> int: nonlocal call_count call_count += 1 @@ -381,7 +381,7 @@ class TestNocacheDecoratorBehavior: """Verify nocache=True skips cache read.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60, token_provider=None) def test_func() -> int: nonlocal call_count call_count += 1 @@ -400,7 +400,7 @@ class TestNocacheDecoratorBehavior: """Verify nocache=True still writes result to cache.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60, token_provider=None) def test_func() -> int: nonlocal call_count call_count += 1 @@ -423,7 +423,7 @@ class TestNocacheDecoratorBehavior: """Verify nocache is intercepted and not passed to decorated function.""" received_kwargs = {} - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60, token_provider=None) def test_func(**kwargs: bool) -> dict: nonlocal received_kwargs received_kwargs = kwargs @@ -506,7 +506,7 @@ class TestNocacheEdgeCases: def test_nocache_with_expired_cache_entry() -> None: """Test nocache behavior when cache entry expired.""" - @cached_operation(ttl=1, use_token=False) # 1 second TTL + @cached_operation(ttl=1, token_provider=None) # 1 second TTL def test_func() -> int: return time.time_ns() @@ -524,7 +524,7 @@ class TestNocacheEdgeCases: """Test multiple consecutive calls with nocache=True.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60, token_provider=None) def test_func() -> int: nonlocal call_count call_count += 1 @@ -544,7 +544,7 @@ class TestNocacheEdgeCases: """Test interleaving nocache=True with normal cached calls.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60, token_provider=None) def test_func() -> int: nonlocal call_count call_count += 1 @@ -702,7 +702,7 @@ docker compose ls --format json | jq -r '.[].Name' | grep ^pytest | xargs -I {} @pytest.fixture def mock_api(): """Mock aignx.codegen API client.""" - api = Mock(spec=PublicApi) + api = Mock(spec=_AuthenticatedApi) api.list_applications.return_value = [...] return api diff --git a/tests/aignostics/platform/authentication_test.py b/tests/aignostics/platform/authentication_test.py index 874969ba..5e21a227 100644 --- a/tests/aignostics/platform/authentication_test.py +++ b/tests/aignostics/platform/authentication_test.py @@ -1320,8 +1320,6 @@ def get_signing_key_side_effect(*args, **kwargs): mock_jwt_client = MagicMock() mock_jwt_client.get_signing_key_from_jwt.side_effect = get_signing_key_side_effect - start_time = time.time() - with ( patch("aignostics.platform._authentication._get_jwk_client", return_value=mock_jwt_client), pytest.raises(RuntimeError, match=AUTHENTICATION_FAILED_TOKEN_VERIFICATION), @@ -1329,12 +1327,10 @@ def get_signing_key_side_effect(*args, **kwargs): ): verify_and_decode_token("valid.token") - elapsed_time = time.time() - start_time - - # Should fail immediately without retries - elapsed time should be < 2 seconds - assert elapsed_time < 2.0, f"Expected fast failure but took {elapsed_time:.2f}s" - - # Verify get_signing_key_from_jwt was called only once (no retries) + # Verify get_signing_key_from_jwt was called only once (no retries) — this directly + # proves the "no retry" semantics. A wall-clock timing assertion was previously here + # too but it was redundant with this call-count check and flaked on slow Windows + # runners (consistently came in around 2.4s vs the 2s threshold). assert call_count == 1 # Verify no retry log messages for non-connection JWK errors diff --git a/tests/aignostics/platform/client_cache_test.py b/tests/aignostics/platform/client_cache_test.py index a3f18435..bea8011b 100644 --- a/tests/aignostics/platform/client_cache_test.py +++ b/tests/aignostics/platform/client_cache_test.py @@ -290,8 +290,8 @@ def test_different_tokens_use_different_cache_entries(mock_settings: MagicMock, mock_me_response_2 = {"user_id": "user-2", "org_id": "org-2"} # Client with token-1 + mock_api_client.token_provider = lambda: "token-1" with ( - patch("aignostics.platform._operation_cache.get_token", return_value="token-1"), patch("aignostics.platform._client.get_token", return_value="token-1"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): @@ -304,8 +304,8 @@ def test_different_tokens_use_different_cache_entries(mock_settings: MagicMock, assert mock_api_client.get_me_v1_me_get.call_count == 1 # Client with token-2 + mock_api_client.token_provider = lambda: "token-2" with ( - patch("aignostics.platform._operation_cache.get_token", return_value="token-2"), patch("aignostics.platform._client.get_token", return_value="token-2"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): @@ -330,13 +330,15 @@ def test_token_change_invalidates_cache(mock_settings: MagicMock, mock_api_clien mock_me_response_1 = {"user_id": "user-1", "org_id": "org-1"} mock_me_response_2 = {"user_id": "user-2", "org_id": "org-2"} + # Use a mutable container so the token provider can be changed mid-test + token_holder = ["token-1"] + mock_api_client.token_provider = lambda: token_holder[0] + # First call with token-1 with ( - patch("aignostics.platform._operation_cache.get_token") as mock_get_token, patch("aignostics.platform._client.get_token", return_value="token-1"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): - mock_get_token.return_value = "token-1" client = Client(cache_token=False) client._api = mock_api_client mock_api_client.get_me_v1_me_get.return_value = mock_me_response_1 @@ -346,7 +348,7 @@ def test_token_change_invalidates_cache(mock_settings: MagicMock, mock_api_clien assert mock_api_client.get_me_v1_me_get.call_count == 1 # Second call with token-2 (simulating token refresh) - mock_get_token.return_value = "token-2" + token_holder[0] = "token-2" mock_api_client.get_me_v1_me_get.return_value = mock_me_response_2 result2 = client.me() @@ -361,13 +363,13 @@ def test_same_token_reuses_cache(mock_settings: MagicMock, mock_api_client: Magi Multiple clients with the same token should share cached values. """ mock_me_response = {"user_id": "test-user", "org_id": "test-org"} + mock_api_client.token_provider = lambda: "token-123" - # First client with token-123 with ( - patch("aignostics.platform._operation_cache.get_token", return_value="token-123"), patch("aignostics.platform._client.get_token", return_value="token-123"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): + # First client with token-123 client1 = Client(cache_token=False) client1._api = mock_api_client mock_api_client.get_me_v1_me_get.return_value = mock_me_response @@ -376,12 +378,7 @@ def test_same_token_reuses_cache(mock_settings: MagicMock, mock_api_client: Magi assert result1 == mock_me_response assert mock_api_client.get_me_v1_me_get.call_count == 1 - # Second client with same token-123 - with ( - patch("aignostics.platform._operation_cache.get_token", return_value="token-123"), - patch("aignostics.platform._client.get_token", return_value="token-123"), - patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), - ): + # Second client with same token-123 client2 = Client(cache_token=False) client2._api = mock_api_client @@ -544,9 +541,9 @@ def test_cache_is_class_level(mock_settings: MagicMock, mock_api_client: MagicMo The _operation_cache should be a class variable, not an instance variable. """ mock_me_response = {"user_id": "test-user", "org_id": "test-org"} + mock_api_client.token_provider = lambda: "token-123" with ( - patch("aignostics.platform._operation_cache.get_token", return_value="token-123"), patch("aignostics.platform._client.get_token", return_value="token-123"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): @@ -575,9 +572,9 @@ def test_cache_cleared_affects_all_clients(mock_settings: MagicMock, mock_api_cl Since cache is class-level, clearing it should affect all instances. """ mock_me_response = {"user_id": "test-user", "org_id": "test-org"} + mock_api_client.token_provider = lambda: "token-123" with ( - patch("aignostics.platform._operation_cache.get_token", return_value="token-123"), patch("aignostics.platform._client.get_token", return_value="token-123"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): @@ -685,9 +682,9 @@ def test_cache_with_very_long_token(mock_settings: MagicMock, mock_api_client: M Cache key should hash long tokens to keep key size manageable. """ long_token = "x" * 10000 # Very long token + mock_api_client.token_provider = lambda: long_token with ( - patch("aignostics.platform._operation_cache.get_token", return_value=long_token), patch("aignostics.platform._client.get_token", return_value=long_token), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): @@ -707,19 +704,19 @@ class TestCacheIntegrationWithAuthentication: @pytest.mark.unit @staticmethod - def test_cache_uses_current_token_from_get_token(mock_settings: MagicMock, mock_api_client: MagicMock) -> None: - """Test that cache always uses the current token from get_token(). + def test_cache_uses_current_token_from_token_provider(mock_settings: MagicMock, mock_api_client: MagicMock) -> None: + """Test that cache always uses the current token from the token provider. - The cache should call get_token() on each operation to get the current token. + The cache should call the token provider on each operation to get the current token. """ mock_me_response = {"user_id": "test-user", "org_id": "test-org"} + token_holder = ["token-1"] + mock_api_client.token_provider = lambda: token_holder[0] with ( - patch("aignostics.platform._operation_cache.get_token") as mock_get_token, patch("aignostics.platform._client.get_token", return_value="token-1"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): - mock_get_token.return_value = "token-1" mock_api_client.get_me_v1_me_get.return_value = mock_me_response client = Client(cache_token=False) @@ -727,10 +724,9 @@ def test_cache_uses_current_token_from_get_token(mock_settings: MagicMock, mock_ # First call with token-1 client.me() - assert mock_get_token.call_count >= 1 # Change token - mock_get_token.return_value = "token-2" + token_holder[0] = "token-2" mock_me_response_2 = {"user_id": "test-user-2", "org_id": "test-org-2"} mock_api_client.get_me_v1_me_get.return_value = mock_me_response_2 @@ -748,14 +744,13 @@ def test_cache_with_token_refresh_scenario(mock_settings: MagicMock, mock_api_cl """ mock_me_response_1 = {"user_id": "user-1", "org_id": "org-1"} mock_me_response_2 = {"user_id": "user-2", "org_id": "org-2"} + token_holder = ["token-initial"] + mock_api_client.token_provider = lambda: token_holder[0] with ( - patch("aignostics.platform._operation_cache.get_token") as mock_get_token, patch("aignostics.platform._client.get_token", return_value="token-initial"), patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api_client), ): - # Initial token - mock_get_token.return_value = "token-initial" mock_api_client.get_me_v1_me_get.return_value = mock_me_response_1 client = Client(cache_token=False) @@ -772,7 +767,7 @@ def test_cache_with_token_refresh_scenario(mock_settings: MagicMock, mock_api_cl assert mock_api_client.get_me_v1_me_get.call_count == 1 # Token refresh happens - mock_get_token.return_value = "token-refreshed" + token_holder[0] = "token-refreshed" mock_api_client.get_me_v1_me_get.return_value = mock_me_response_2 # Call 3: New token means cache miss, fetches new data diff --git a/tests/aignostics/platform/client_token_provider_test.py b/tests/aignostics/platform/client_token_provider_test.py index e787f211..d14b5162 100644 --- a/tests/aignostics/platform/client_token_provider_test.py +++ b/tests/aignostics/platform/client_token_provider_test.py @@ -1,10 +1,23 @@ """Tests for the token provider configuration and its integration with the client.""" +from collections.abc import Callable from unittest.mock import Mock, patch import pytest -from aignostics.platform._client import Client, _OAuth2TokenProviderConfiguration +from aignostics.platform._api import _AuthenticatedApi, _AuthenticatedResource, _OAuth2TokenProviderConfiguration +from aignostics.platform._client import Client + +# Module-level constants for repeated string literals (extracted to satisfy +# SonarQube python:S1192 — "Define a constant instead of duplicating this literal"). +# These are unittest.mock.patch() targets (Python module paths), not credentials. +# The noqa on _GET_TOKEN_PATCH is needed because ruff's hardcoded-password +# detector (S105) treats any constant whose name contains "TOKEN" as a credential — +# a false positive for a patch-target string. The bare `# noqa: S105` form keeps +# the suppression syntactically valid for SonarQube python:S7632. +_DUMMY_HOST = "https://dummy" +_GET_TOKEN_PATCH = "aignostics.platform._client.get_token" # noqa: S105 +_APICLIENT_PATCH = "aignostics.platform._client.ApiClient" @pytest.fixture(autouse=True) @@ -12,13 +25,23 @@ def _clear_api_client_cache() -> None: """Clear the API client cache before each test to ensure test isolation.""" Client._api_client_cached = None Client._api_client_uncached = None + Client._api_client_external.clear() + + +def _make_provider(token: str) -> Callable[[], str]: + """Create a token provider that returns the given token string.""" + + def provider() -> str: + return token + + return provider @pytest.mark.unit def test_oauth2_token_provider_configuration_uses_token_provider() -> None: """Test that token_provider is used when provided.""" token_provider = Mock(return_value="dynamic-token") - config = _OAuth2TokenProviderConfiguration(host="https://dummy", token_provider=token_provider) + config = _OAuth2TokenProviderConfiguration(host=_DUMMY_HOST, token_provider=token_provider) auth = config.auth_settings() assert auth["OAuth2AuthorizationCodeBearer"]["value"] == "Bearer dynamic-token" token_provider.assert_called_once() @@ -27,7 +50,7 @@ def test_oauth2_token_provider_configuration_uses_token_provider() -> None: @pytest.mark.unit def test_oauth2_token_provider_configuration_no_token() -> None: """Test that auth_settings returns empty dict if no token_provider is set.""" - config = _OAuth2TokenProviderConfiguration(host="https://dummy") + config = _OAuth2TokenProviderConfiguration(host=_DUMMY_HOST) auth = config.auth_settings() assert auth == {} @@ -36,29 +59,245 @@ def test_oauth2_token_provider_configuration_no_token() -> None: def test_client_passes_token_provider() -> None: """Test that the client passes the token provider to the configuration.""" with ( - patch("aignostics.platform._client.get_token", return_value="client-token"), - patch("aignostics.platform._client.ApiClient") as api_client_mock, - patch("aignostics.platform._client.PublicApi") as public_api_mock, + patch(_GET_TOKEN_PATCH, return_value="client-token"), + patch(_APICLIENT_PATCH) as api_client_mock, ): Client(cache_token=False) config_used = api_client_mock.call_args[0][0] assert isinstance(config_used, _OAuth2TokenProviderConfiguration) assert config_used.token_provider() == "client-token" - public_api_mock.assert_called() @pytest.mark.unit def test_client_me_calls_api() -> None: """Test that the client.me() method calls the API and returns the result.""" with ( - patch("aignostics.platform._client.get_token", return_value="client-token"), - patch("aignostics.platform._client.ApiClient"), - patch("aignostics.platform._client.PublicApi") as public_api_mock, + patch(_GET_TOKEN_PATCH, return_value="client-token"), + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None) as _, ): + client = Client() + # Manually set up the mock api on the client api_instance = Mock() api_instance.get_me_v1_me_get.return_value = "me-info" - public_api_mock.return_value = api_instance - client = Client() + api_instance.token_provider = lambda: "client-token" + client._api = api_instance result = client.me() assert result == "me-info" api_instance.get_me_v1_me_get.assert_called_once() + + +# --- External token provider tests --- + + +@pytest.mark.unit +def test_client_with_external_token_provider() -> None: + """Test that Client accepts an external token provider and initializes successfully.""" + my_provider = _make_provider("my-m2m-token") + + with ( + patch(_APICLIENT_PATCH) as api_client_mock, + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + Client(token_provider=my_provider) + + # Verify the config received the external provider + config_used = api_client_mock.call_args[0][0] + assert isinstance(config_used, _OAuth2TokenProviderConfiguration) + assert config_used.token_provider is my_provider + + +@pytest.mark.unit +def test_external_provider_bypasses_oauth() -> None: + """Test that get_token is NOT called when an external token provider is used.""" + my_provider = _make_provider("external-token") + + with ( + patch(_GET_TOKEN_PATCH) as mock_get_token, + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + Client(token_provider=my_provider) + mock_get_token.assert_not_called() + + +@pytest.mark.unit +def test_external_provider_token_in_auth_header() -> None: + """Test that the external provider's token appears in the Authorization header.""" + my_provider = _make_provider("bearer-value-123") + + with ( + patch(_APICLIENT_PATCH) as api_client_mock, + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + Client(token_provider=my_provider) + config_used = api_client_mock.call_args[0][0] + auth = config_used.auth_settings() + assert auth["OAuth2AuthorizationCodeBearer"]["value"] == "Bearer bearer-value-123" + + +@pytest.mark.unit +def test_external_provider_singleton_isolation() -> None: + """Test that different providers get different API client instances.""" + provider_a = _make_provider("token-a") + provider_b = _make_provider("token-b") + + with ( + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + client_a = Client(token_provider=provider_a) + client_b = Client(token_provider=provider_b) + + assert client_a._api is not client_b._api + + +@pytest.mark.unit +def test_external_provider_same_provider_reused() -> None: + """Test that the same provider callable reuses the cached API client.""" + my_provider = _make_provider("reuse-token") + + with ( + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + client1 = Client(token_provider=my_provider) + client2 = Client(token_provider=my_provider) + + assert client1._api is client2._api + + +@pytest.mark.unit +def test_cache_token_false_with_external_provider_is_allowed() -> None: + """Test that cache_token=False is silently ignored when token_provider is set.""" + with ( + patch(_GET_TOKEN_PATCH) as mock_get_token, + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + # Should not raise; cache_token is irrelevant when using an external provider + Client(token_provider=_make_provider("token"), cache_token=False) + mock_get_token.assert_not_called() + + +@pytest.mark.unit +def test_cache_token_default_with_external_provider_ok() -> None: + """Test that default cache_token=True works with an external token provider.""" + with ( + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + ): + # Should not raise + Client(token_provider=_make_provider("token")) + + +@pytest.mark.unit +def test_falsy_token_provider_logs_warning() -> None: + """Test that a warning is logged when token_provider returns an empty string.""" + empty_provider = _make_provider("") + config = _OAuth2TokenProviderConfiguration(host=_DUMMY_HOST, token_provider=empty_provider) + + with patch("aignostics.platform._api.logger") as mock_logger: + result = config.auth_settings() + + assert result == {} + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args[0][0] + assert "empty or None token" in warning_msg + + +@pytest.mark.unit +def test_none_token_provider_no_warning() -> None: + """Test that no warning is logged when token_provider is not set (None).""" + config = _OAuth2TokenProviderConfiguration(host=_DUMMY_HOST) + + with patch("aignostics.platform._api.logger") as mock_logger: + result = config.auth_settings() + + assert result == {} + mock_logger.warning.assert_not_called() + + +@pytest.mark.unit +def test_external_provider_cache_bounded() -> None: + """Test that _api_client_external is bounded to _MAX_EXTERNAL_CLIENTS entries.""" + from aignostics.platform._client import _MAX_EXTERNAL_CLIENTS + + with ( + patch(_APICLIENT_PATCH), + patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None), + patch("aignostics.platform._client.logger") as mock_logger, + ): + # Create more clients than the limit, each with a distinct provider + for i in range(_MAX_EXTERNAL_CLIENTS + 5): + Client(token_provider=_make_provider(f"token-{i}")) + + # Cache must not exceed the limit (cleared + 1 new entry after overflow) + assert len(Client._api_client_external) <= _MAX_EXTERNAL_CLIENTS + + # A warning should have been logged when the cache was cleared + mock_logger.warning.assert_called() + warning_msg = mock_logger.warning.call_args[0][0] + assert "resource leak" in warning_msg + + +# --- Integration tests --- + + +@pytest.mark.integration +def test_external_provider_wires_through_to_resources() -> None: + """Integration: Client(token_provider=...) wires through real constructors. + + Verifies that an external token provider flows through Client → _AuthenticatedApi → + resource classes (Applications, Runs) without any AttributeError. Only the + ApiClient constructor is mocked to avoid real HTTP calls. + """ + my_provider = _make_provider("integration-test-token") + + with patch(_APICLIENT_PATCH) as mock_api_client_cls: + # Create client with external provider — real _AuthenticatedApi and + # resource constructors (_AuthenticatedResource.__init__) run. + client = Client(token_provider=my_provider) + + # Verify the provider is wired through the real _AuthenticatedApi + assert isinstance(client._api, _AuthenticatedApi) + assert client._api.token_provider is my_provider + + # Verify resources received the same _AuthenticatedApi instance + assert client.applications._api is client._api + assert client.runs._api is client._api + assert client.versions._api is client._api + + # Verify the Configuration passed to ApiClient produces the correct auth header + config = mock_api_client_cls.call_args[0][0] + assert isinstance(config, _OAuth2TokenProviderConfiguration) + auth = config.auth_settings() + assert auth["OAuth2AuthorizationCodeBearer"]["value"] == "Bearer integration-test-token" + + +# --- Runtime guard tests --- + + +@pytest.mark.unit +def test_authenticated_resource_rejects_non_authenticated_api() -> None: + """`_AuthenticatedResource.__init__` raises TypeError for non-`_AuthenticatedApi` inputs. + + The runtime guard exists to catch callers that bypass `Client` and construct + resource classes directly with a plain codegen `PublicApi` (or any other object + lacking `token_provider`). Without this guard, the `AttributeError` would only + surface much later at the first cached call site, with a confusing message. + """ + + class NotAnAuthenticatedApi: + """Stand-in for a plain `PublicApi` or arbitrary object.""" + + with pytest.raises(TypeError, match="requires _AuthenticatedApi"): + _AuthenticatedResource(NotAnAuthenticatedApi()) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_authenticated_resource_accepts_authenticated_api() -> None: + """`_AuthenticatedResource.__init__` stores the api and exposes it as `_api`.""" + api = Mock(spec=_AuthenticatedApi) + resource = _AuthenticatedResource(api) + assert resource._api is api diff --git a/tests/aignostics/platform/conftest.py b/tests/aignostics/platform/conftest.py index 4a4d7df2..c4e8e527 100644 --- a/tests/aignostics/platform/conftest.py +++ b/tests/aignostics/platform/conftest.py @@ -5,6 +5,7 @@ import pytest +from aignostics.platform._api import _AuthenticatedApi from aignostics.platform._client import Client from aignostics.platform._operation_cache import _operation_cache from aignostics.platform._service import Service @@ -49,9 +50,9 @@ def mock_api_client() -> MagicMock: """Provide a mock API client. Returns: - MagicMock: A mock of the PublicApi client. + MagicMock: A mock of the _AuthenticatedApi client. """ - return MagicMock() + return MagicMock(spec=_AuthenticatedApi) @pytest.fixture(autouse=True) @@ -64,11 +65,13 @@ def clear_cache() -> t.Generator[None, None, None]: _operation_cache.clear() Client._api_client_cached = None Client._api_client_uncached = None + Client._api_client_external.clear() Service._http_pool = None yield _operation_cache.clear() Client._api_client_cached = None Client._api_client_uncached = None + Client._api_client_external.clear() Service._http_pool = None @@ -88,6 +91,7 @@ def client_with_mock_api(mock_api_client: MagicMock) -> t.Generator[Client, None "exp": 9999999999, "iss": "test-issuer", } + mock_api_client.token_provider = lambda: "test-token-123" with ( patch("aignostics.platform._client.get_token", return_value="test-token-123"), patch("aignostics.platform._authentication.verify_and_decode_token", return_value=mock_token_claims), diff --git a/tests/aignostics/platform/nocache_test.py b/tests/aignostics/platform/nocache_test.py index 364b8260..7a50a9c3 100644 --- a/tests/aignostics/platform/nocache_test.py +++ b/tests/aignostics/platform/nocache_test.py @@ -25,7 +25,7 @@ def test_decorator_without_nocache_uses_cache() -> None: """Test that decorated function uses cache by default (nocache=False).""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -47,7 +47,7 @@ def test_decorator_with_nocache_false_uses_cache() -> None: """Test that nocache=False explicitly uses cache.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -69,7 +69,7 @@ def test_decorator_with_nocache_true_skips_reading_cache() -> None: """Test that nocache=True skips reading from cache.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -91,7 +91,7 @@ def test_decorator_with_nocache_true_still_writes_to_cache() -> None: """Test that nocache=True still writes the result to cache.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -118,7 +118,7 @@ def test_decorator_nocache_parameter_not_passed_to_function() -> None: """Test that nocache parameter is intercepted and not passed to the decorated function.""" received_kwargs = {} - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func(**kwargs: bool) -> dict: nonlocal received_kwargs received_kwargs = kwargs @@ -136,7 +136,7 @@ def test_decorator_with_nocache_and_other_kwargs() -> None: """Test that nocache works alongside other keyword arguments.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func(param1: str = "default", param2: int = 0) -> tuple: nonlocal call_count call_count += 1 @@ -163,7 +163,7 @@ def test_decorator_nocache_with_different_cache_keys() -> None: """Test that nocache respects different cache keys (different args).""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func(key: str) -> tuple: nonlocal call_count call_count += 1 @@ -512,7 +512,7 @@ def test_nocache_with_expired_cache_entry() -> None: """Test nocache behavior when cache entry has expired.""" call_count = 0 - @cached_operation(ttl=1, use_token=False) # 1 second TTL + @cached_operation(ttl=1) # 1 second TTL def test_func() -> int: nonlocal call_count call_count += 1 @@ -537,7 +537,7 @@ def test_nocache_clears_expired_entry_before_writing_new() -> None: """Test that nocache properly handles expired entries.""" call_count = 0 - @cached_operation(ttl=1, use_token=False) + @cached_operation(ttl=1) def test_func() -> int: nonlocal call_count call_count += 1 @@ -565,7 +565,7 @@ def test_multiple_consecutive_nocache_calls() -> None: """Test multiple consecutive calls with nocache=True.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -594,7 +594,7 @@ def test_nocache_interleaved_with_normal_calls() -> None: """Test interleaving nocache=True with normal cached calls.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -640,7 +640,7 @@ def test_nocache_after_cache_clear() -> None: """Test that nocache works correctly after cache has been cleared.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 @@ -667,7 +667,7 @@ def test_cache_clear_removes_nocache_populated_entries() -> None: """Test that cache clear removes entries populated with nocache=True.""" call_count = 0 - @cached_operation(ttl=60, use_token=False) + @cached_operation(ttl=60) def test_func() -> int: nonlocal call_count call_count += 1 diff --git a/tests/aignostics/platform/resources/applications_test.py b/tests/aignostics/platform/resources/applications_test.py index 2debff4b..6067339d 100644 --- a/tests/aignostics/platform/resources/applications_test.py +++ b/tests/aignostics/platform/resources/applications_test.py @@ -11,12 +11,12 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from aignx.codegen.api.public_api import PublicApi from aignx.codegen.exceptions import NotFoundException from aignx.codegen.models.application_read_response import ApplicationReadResponse from aignx.codegen.models.version_document_response import VersionDocumentResponse from aignx.codegen.models.version_document_visibility import VersionDocumentVisibility +from aignostics.platform._api import _AuthenticatedApi from aignostics.platform._operation_cache import operation_cache_clear from aignostics.platform.resources.applications import ( Applications, @@ -42,7 +42,10 @@ def mock_api() -> Mock: Returns: Mock: A mock instance of ExternalsApi. """ - return Mock(spec=PublicApi) + api = Mock(spec=_AuthenticatedApi) + api.token_provider = lambda: "test-token" + api.api_client = Mock() + return api @pytest.fixture diff --git a/tests/aignostics/platform/resources/runs_test.py b/tests/aignostics/platform/resources/runs_test.py index 1de38ebb..3843a70c 100644 --- a/tests/aignostics/platform/resources/runs_test.py +++ b/tests/aignostics/platform/resources/runs_test.py @@ -9,7 +9,6 @@ import pytest import requests -from aignx.codegen.api.public_api import PublicApi from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException from aignx.codegen.models import ( InputArtifactCreationRequest, @@ -19,6 +18,7 @@ RunReadResponse, ) +from aignostics.platform._api import _AuthenticatedApi from aignostics.platform.resources.runs import LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, Artifact, Run, Runs from aignostics.platform.resources.utils import PAGE_SIZE @@ -31,6 +31,28 @@ _PATCH_SETTINGS = "aignostics.platform.resources.runs.settings" +def _redirect_response(location: str | None, status: int = HTTPStatus.TEMPORARY_REDIRECT) -> MagicMock: + """Build a context-manager-shaped Mock response with the given status + Location.""" + response = MagicMock() + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + response.status_code = status + response.headers = {"Location": location} if location is not None else {} + response.reason = HTTPStatus(status).phrase or "Unknown" + return response + + +def _error_response(status: int) -> MagicMock: + """Build a context-manager-shaped Mock response with the given non-redirect status.""" + response = MagicMock() + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + response.status_code = status + response.headers = {} + response.reason = HTTPStatus(status).phrase if status in HTTPStatus._value2member_map_ else "Unknown" + return response + + @pytest.fixture def mock_api() -> Mock: """Create a mock ExternalsApi object for testing. @@ -38,7 +60,10 @@ def mock_api() -> Mock: Returns: Mock: A mock instance of ExternalsApi. """ - return Mock(spec=PublicApi) + api = Mock(spec=_AuthenticatedApi) + api.token_provider = lambda: "test-token" + api.api_client = Mock() + return api @pytest.fixture @@ -64,7 +89,7 @@ def app_run(mock_api) -> Run: Returns: Run: An Run instance using the mock API. """ - return Run(mock_api, _RUN_ID) + return Run(mock_api, "test-run-id") @pytest.fixture @@ -78,7 +103,6 @@ def configured_api(mock_api) -> Mock: Tests that need to verify ``token_provider`` propagation should set it explicitly. """ - mock_api.api_client = Mock() mock_api.api_client.configuration.host = _PLATFORM_HOST mock_api.api_client.configuration.proxy = None mock_api.api_client.configuration.ssl_ca_cert = None @@ -93,28 +117,6 @@ def artifact(configured_api) -> Artifact: return Artifact(configured_api, _RUN_ID, _ARTIFACT_ID) -def _redirect_response(location: str | None, status: int = HTTPStatus.TEMPORARY_REDIRECT) -> MagicMock: - """Build a context-manager-shaped Mock response with the given status + Location.""" - response = MagicMock() - response.__enter__ = Mock(return_value=response) - response.__exit__ = Mock(return_value=False) - response.status_code = status - response.headers = {"Location": location} if location is not None else {} - response.reason = HTTPStatus(status).phrase or "Unknown" - return response - - -def _error_response(status: int) -> MagicMock: - """Build a context-manager-shaped Mock response with the given non-redirect status.""" - response = MagicMock() - response.__enter__ = Mock(return_value=response) - response.__exit__ = Mock(return_value=False) - response.status_code = status - response.headers = {} - response.reason = HTTPStatus(status).phrase - return response - - @pytest.mark.unit def test_runs_list_with_pagination(runs, mock_api) -> None: """Test that Runs.list() correctly handles pagination. @@ -892,12 +894,7 @@ def test_artifact_get_download_url_4xx_raises_api_exception(artifact, client_sta @pytest.mark.unit def test_artifact_get_download_url_unexpected_2xx_raises_runtime(artifact) -> None: - """A 200 (or other unexpected non-error, non-redirect) is RuntimeError. - - Per Dima's clarification on PR #478: the endpoint never returns 200 in - practice. If it ever does, we fail explicitly rather than silently passing - a body off to webbrowser.open(). - """ + """A 200 (or other unexpected non-error, non-redirect) is RuntimeError.""" with ( patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=_error_response(HTTPStatus.OK)), @@ -926,7 +923,7 @@ def test_artifact_get_download_url_5xx_retries_then_succeeds(artifact) -> None: url = artifact.get_download_url() assert url == _PRESIGNED_URL - assert mock_get.call_count == 2 # one retry was needed + assert mock_get.call_count == 2 @pytest.mark.unit @@ -961,13 +958,9 @@ def test_artifact_get_download_url_5xx_exhausts_retries_then_raises(artifact) -> ], ) def test_artifact_get_download_url_network_errors_become_service_exception(artifact, exc_factory) -> None: - """`requests` exceptions are wrapped as ServiceException so retry can act on them. - - Without this wrapping the e2e tests in PR #507 hung — `requests.HTTPError` - escaped the retry loop and surfaced as a wrong exception type. - """ + """`requests` exceptions are wrapped as ServiceException so retry can act on them.""" fake_settings = Mock() - fake_settings.run_retry_attempts = 1 # don't waste test time on retries + fake_settings.run_retry_attempts = 1 fake_settings.run_retry_wait_min = 0.0 fake_settings.run_retry_wait_max = 0.0 fake_settings.run_timeout = 5.0 @@ -983,22 +976,13 @@ def test_artifact_get_download_url_network_errors_become_service_exception(artif @pytest.mark.unit def test_artifact_get_download_url_honors_configuration_token_provider(configured_api) -> None: - """When configuration.token_provider is set, Artifact uses it instead of get_token. - - The codegen Client wires up token_provider to call ``get_token(use_cache=cache_token)``. - A user who instantiates ``Client(cache_token=False)`` does not want the SDK to - read/write the token file when the SDK resolves artifact URLs. This test pins - that contract — without it, Copilot's PR review caught us bypassing the user's - cache preference. - """ + """When configuration.token_provider is set, Artifact uses it instead of get_token.""" custom_token_provider = Mock(return_value="cache-disabled-token") configured_api.api_client.configuration.token_provider = custom_token_provider art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID) response = _redirect_response(_PRESIGNED_URL) with ( - # If the implementation falls back to get_token here, the test would still - # pass — so we patch get_token to a sentinel value the assertion would catch. patch(_PATCH_GET_TOKEN, return_value="WRONG-from-fallback"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get, ): @@ -1010,11 +994,7 @@ def test_artifact_get_download_url_honors_configuration_token_provider(configure @pytest.mark.unit def test_artifact_get_download_url_passes_proxy_and_ca_bundle(configured_api) -> None: - """Proxy and custom CA bundle from codegen Configuration are honored. - - Enterprise installs frequently set these via env; a previous draft of this - code ignored them, which would have broken downloads behind a proxy. - """ + """Proxy and custom CA bundle from codegen Configuration are honored.""" proxy_url = "https://corp-proxy.local:3128" configured_api.api_client.configuration.proxy = proxy_url configured_api.api_client.configuration.ssl_ca_cert = "/etc/ssl/corp-ca.pem" @@ -1030,22 +1010,17 @@ def test_artifact_get_download_url_passes_proxy_and_ca_bundle(configured_api) -> kwargs = mock_get.call_args.kwargs assert kwargs["proxies"] == {"http": proxy_url, "https": proxy_url} - # CA bundle path takes precedence over verify_ssl=False assert kwargs["verify"] == "/etc/ssl/corp-ca.pem" @pytest.mark.unit def test_run_get_artifact_download_url_delegates_to_artifact(app_run, configured_api) -> None: - """Run.get_artifact_download_url is the documented entry point and must just delegate. - - Keeping this thin protects callers from internal refactors of `Artifact`. - """ + """Run.get_artifact_download_url is the documented entry point and must just delegate.""" response = _redirect_response(_PRESIGNED_URL) with ( patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response), ): - # Replace mock_api on the existing Run with the configured one app_run._api = configured_api url = app_run.get_artifact_download_url(_ARTIFACT_ID) assert url == _PRESIGNED_URL @@ -1096,11 +1071,7 @@ def _make_item_mock(external_id: str = "slide-1", artifacts: list | None = None) @pytest.mark.unit def test_ensure_artifacts_downloaded_resolves_fresh_url_per_artifact(app_run, tmp_path) -> None: - """ensure_artifacts_downloaded must call get_artifact_download_url for AVAILABLE artifacts. - - The deprecated artifact.download_url field is no longer consulted; URL is - resolved fresh per artifact via the /file endpoint right before downloading. - """ + """ensure_artifacts_downloaded must call get_artifact_download_url for AVAILABLE artifacts.""" item = _make_item_mock(artifacts=[_make_artifact_mock(output_artifact_id="art-xyz")]) app_run.get_artifact_download_url = Mock(return_value=_PRESIGNED_URL) @@ -1110,17 +1081,12 @@ def test_ensure_artifacts_downloaded_resolves_fresh_url_per_artifact(app_run, tm app_run.get_artifact_download_url.assert_called_once_with("art-xyz") mock_download.assert_called_once() call_args = mock_download.call_args.args - assert call_args[0] == _PRESIGNED_URL # First arg is the freshly resolved URL + assert call_args[0] == _PRESIGNED_URL @pytest.mark.unit def test_ensure_artifacts_downloaded_skips_non_available_artifacts(app_run, tmp_path) -> None: - """Non-AVAILABLE artifacts must be skipped — the /file endpoint won't return URLs for them. - - Per Dima on PR #478: the /file endpoint does not return a presigned URL for - artifacts whose output is NONE (e.g. deleted, never produced). Calling it - would fail the entire download. This test pins the AVAILABLE-gating. - """ + """Non-AVAILABLE artifacts must be skipped.""" from aignx.codegen.models import ArtifactOutput as _ArtifactOutput none_artifact = _make_artifact_mock(output_artifact_id="art-none", output=_ArtifactOutput.NONE) @@ -1136,12 +1102,7 @@ def test_ensure_artifacts_downloaded_skips_non_available_artifacts(app_run, tmp_ @pytest.mark.unit def test_ensure_artifacts_downloaded_skips_existing_file_with_matching_checksum(app_run, tmp_path) -> None: - """If a local file already matches the metadata checksum, skip the URL fetch and the download. - - Critical: do NOT call get_artifact_download_url when we wouldn't have downloaded - anyway. Resolving a presigned URL hits SAMIA; skipping it on resume saves - backend load and shortens resume cycles. - """ + """If a local file already matches the metadata checksum, skip the URL fetch and download.""" item_dir = tmp_path / "slide-1" item_dir.mkdir() artifact_path = item_dir / "result.csv" @@ -1150,7 +1111,7 @@ def test_ensure_artifacts_downloaded_skips_existing_file_with_matching_checksum( app_run.get_artifact_download_url = Mock() with ( - patch(_PATCH_CALC_CRC32C, return_value="AAAA"), # Matches metadata checksum + patch(_PATCH_CALC_CRC32C, return_value="AAAA"), patch(_PATCH_MIME_TYPE_TO_FILE_ENDING, return_value=".csv"), patch(_PATCH_DOWNLOAD_FILE_RUNS) as mock_download, ): @@ -1171,7 +1132,7 @@ def test_ensure_artifacts_downloaded_resumes_when_local_checksum_mismatches(app_ app_run.get_artifact_download_url = Mock(return_value=_PRESIGNED_URL) with ( - patch(_PATCH_CALC_CRC32C, return_value="ZZZZ"), # Mismatch with metadata "AAAA" + patch(_PATCH_CALC_CRC32C, return_value="ZZZZ"), patch(_PATCH_MIME_TYPE_TO_FILE_ENDING, return_value=".csv"), patch(_PATCH_DOWNLOAD_FILE_RUNS) as mock_download, ): @@ -1183,13 +1144,7 @@ def test_ensure_artifacts_downloaded_resumes_when_local_checksum_mismatches(app_ @pytest.mark.unit def test_ensure_artifacts_downloaded_skips_artifact_with_no_metadata(app_run, tmp_path) -> None: - """Artifact with empty metadata dict is skipped (no checksum to verify against). - - Per Copilot PR review on #598, the metadata check now runs *before* the - MIME lookup, so this test no longer needs to mock through the MIME helpers. - Without the reorder, an empty-metadata artifact would raise ``ValueError`` - from ``mime_type_to_file_ending`` before the early-return could fire. - """ + """Artifact with empty metadata dict is skipped (no checksum to verify against).""" item = _make_item_mock(artifacts=[_make_artifact_mock(metadata={})]) app_run.get_artifact_download_url = Mock() @@ -1202,22 +1157,13 @@ def test_ensure_artifacts_downloaded_skips_artifact_with_no_metadata(app_run, tm @pytest.mark.unit def test_download_to_folder_post_termination_loop_filters_by_item_state(app_run, mock_api, tmp_path) -> None: - """Post-termination loop must filter items by state==TERMINATED and output==FULL. - - Regression guard for the latent enum-truthiness bug Sentry flagged on PR #478: - the original code had ``if ItemOutput.FULL:`` (always truthy because it's a - member-existence check, not a value comparison), which caused - ensure_artifacts_downloaded to be called for *every* item regardless of state - or output. This test pins that we only download items that are actually - terminated with full output. - """ + """Post-termination loop must filter items by state==TERMINATED and output==FULL.""" from aignx.codegen.models import ItemOutput, ItemState, RunState terminated_full_item = MagicMock(state=ItemState.TERMINATED, output=ItemOutput.FULL, external_id="ok") terminated_none_item = MagicMock(state=ItemState.TERMINATED, output=ItemOutput.NONE, external_id="empty") pending_item = MagicMock(state=ItemState.PROCESSING, output=ItemOutput.NONE, external_id="pending") - # Run is already TERMINATED on first details() call → skip the wait loop, go to post-loop. terminated_run_state = MagicMock(state=RunState.TERMINATED) app_run.details = Mock(return_value=terminated_run_state) app_run.results = Mock(return_value=[terminated_full_item, terminated_none_item, pending_item]) @@ -1225,8 +1171,6 @@ def test_download_to_folder_post_termination_loop_filters_by_item_state(app_run, app_run.download_to_folder(tmp_path, print_status=False) - # Only the TERMINATED+FULL item should trigger ensure_artifacts_downloaded. - # If the latent enum bug were back, all 3 items would trigger it. assert app_run.ensure_artifacts_downloaded.call_count == 1 forwarded_item = app_run.ensure_artifacts_downloaded.call_args.args[1] assert forwarded_item.external_id == "ok" @@ -1234,12 +1178,6 @@ def test_download_to_folder_post_termination_loop_filters_by_item_state(app_run, @pytest.mark.unit def test_ensure_artifacts_downloaded_is_instance_method_not_static(app_run, tmp_path) -> None: - """Regression guard: ensure_artifacts_downloaded must be an instance method. - - PR #478 left it as @staticmethod, which made `self.get_artifact_download_url()` - inside the loop impossible. Pinning the bound-method shape so a future refactor - cannot accidentally revert. - """ + """Regression guard: ensure_artifacts_downloaded must be an instance method.""" bound = app_run.ensure_artifacts_downloaded - # On a method, __self__ is the instance; on a staticmethod, __self__ doesn't exist. assert getattr(bound, "__self__", None) is app_run diff --git a/tests/aignostics/platform/settings_test.py b/tests/aignostics/platform/settings_test.py index 9a20ad34..5f1645c8 100644 --- a/tests/aignostics/platform/settings_test.py +++ b/tests/aignostics/platform/settings_test.py @@ -67,17 +67,14 @@ def mock_env_vars(): # noqa: ANN201 @pytest.fixture def reset_cached_settings(): # noqa: ANN201 """Reset the cached authentication settings.""" - from aignostics.platform._settings import __cached_settings + import aignostics.platform._settings as _settings_module - # Store original - original = __cached_settings - - settings.__cached_settings = None + original = _settings_module.__cached_settings + _settings_module.__cached_settings = None yield - # Restore original - settings.__cached_settings = original + _settings_module.__cached_settings = original @pytest.mark.unit diff --git a/uv.lock b/uv.lock index 3810b75a..033e5dd5 100644 --- a/uv.lock +++ b/uv.lock @@ -101,8 +101,10 @@ dependencies = [ jupyter = [ { name = "jupyter" }, { name = "jupyter-core" }, + { name = "jupyter-server" }, { name = "jupyterlab" }, { name = "nbconvert" }, + { name = "notebook" }, ] marimo = [ { name = "cloudpathlib" }, @@ -199,6 +201,7 @@ requires-dist = [ { name = "jsonschema", extras = ["format-nongpl"], specifier = ">=4.25.1,<5" }, { name = "jupyter", marker = "extra == 'jupyter'", specifier = ">=1.1.1,<2" }, { name = "jupyter-core", marker = "extra == 'jupyter'", specifier = ">=5.8.1" }, + { name = "jupyter-server", marker = "extra == 'jupyter'", specifier = ">=2.18.0" }, { name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.4.9" }, { name = "loguru", specifier = ">=0.7.3,<1" }, { name = "lxml", specifier = ">=6.1.0" }, @@ -208,6 +211,7 @@ requires-dist = [ { name = "matplotlib", marker = "extra == 'marimo'", specifier = ">=3.10.7,<4" }, { name = "nbconvert", marker = "extra == 'jupyter'", specifier = ">=7.17.1" }, { name = "nicegui", extras = ["native"], specifier = ">=3.11.0,<4" }, + { name = "notebook", marker = "extra == 'jupyter'", specifier = ">=7.5.6" }, { name = "openslide-bin", specifier = ">=4.0.0.10,<5" }, { name = "openslide-python", specifier = ">=1.4.3,<2" }, { name = "packaging", specifier = ">=26,<27" }, @@ -3192,7 +3196,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.17.0" +version = "2.18.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3215,9 +3219,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/b0/666586d557a71a58cd9960b154fb9aee0ed81dd62a50371195ab95731909/jupyter_server-2.18.1.tar.gz", hash = "sha256:f62be526369b791625e03bd658070563c1a4e9a0a2f439ea1f9dbacea5f7191a", size = 752024, upload-time = "2026-05-05T09:17:51.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/a4/45/bfe3779fd06714a379128f2c4eaf7c99414f0eb081f9f34c135f6b3d511c/jupyter_server-2.18.1-py3-none-any.whl", hash = "sha256:db0374d52a975f88a92a7f20de44e08ef5be9763ba7e99630baf16c46ac8dbf0", size = 391844, upload-time = "2026-05-05T09:17:48.521Z" }, ] [[package]] @@ -3235,7 +3239,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.5.6" +version = "4.5.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -3252,9 +3256,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/d5/730628e03fff2e8a8e8ccdaedde1489ab1309f9a4fa2536248884e30b7c7/jupyterlab-4.5.6.tar.gz", hash = "sha256:642fe2cfe7f0f5922a8a558ba7a0d246c7bc133b708dfe43f7b3a826d163cf42", size = 23970670, upload-time = "2026-03-11T14:17:04.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/1b/dad6fdcc658ed7af26fdf3841e7394072c9549a8b896c381ab49dd11e2d9/jupyterlab-4.5.6-py3-none-any.whl", hash = "sha256:d6b3dac883aa4d9993348e0f8e95b24624f75099aed64eab6a4351a9cdd1e580", size = 12447124, upload-time = "2026-03-11T14:17:00.229Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" }, ] [[package]] @@ -4477,7 +4481,7 @@ wheels = [ [[package]] name = "notebook" -version = "7.5.5" +version = "7.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, @@ -4486,9 +4490,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/6d/41052c48d6f6349ca0a7c4d1f6a78464de135e6d18f5829ba2510e62184c/notebook-7.5.5.tar.gz", hash = "sha256:dc0bfab0f2372c8278c457423d3256c34154ac2cc76bf20e9925260c461013c3", size = 14169167, upload-time = "2026-03-11T16:32:51.922Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/cbd1deb9f07446241e88f8d5fecccd95b249bca0b4e5482214a4d1714c49/notebook-7.5.5-py3-none-any.whl", hash = "sha256:a7c14dbeefa6592e87f72290ca982e0c10f5bbf3786be2a600fda9da2764a2b8", size = 14578929, upload-time = "2026-03-11T16:32:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" }, ] [[package]]