Skip to content

Commit b407d30

Browse files
committed
fixup! feat(platform): support external token providers and simplify caching
1 parent ca1f4c0 commit b407d30

7 files changed

Lines changed: 75 additions & 34 deletions

File tree

src/aignostics/platform/_api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from aignx.codegen.api.public_api import PublicApi
1212
from aignx.codegen.api_client import ApiClient
1313
from aignx.codegen.configuration import AuthSettings, Configuration
14+
from loguru import logger
1415

1516

1617
class _OAuth2TokenProviderConfiguration(Configuration):
@@ -29,6 +30,11 @@ def __init__(
2930
def auth_settings(self) -> AuthSettings:
3031
token = self.token_provider() if self.token_provider else None
3132
if not token:
33+
if self.token_provider is not None:
34+
logger.warning(
35+
"token_provider returned an empty or None token; "
36+
"request will proceed without an Authorization header"
37+
)
3238
return {}
3339
return {
3440
"OAuth2AuthorizationCodeBearer": {

src/aignostics/platform/_client.py

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import weakref
23
from collections.abc import Callable
34
from typing import ClassVar
45
from urllib.request import getproxies
@@ -69,7 +70,9 @@ class Client:
6970

7071
_api_client_cached: ClassVar[_AuthenticatedApi | None] = None
7172
_api_client_uncached: ClassVar[_AuthenticatedApi | None] = None
72-
_api_client_external: ClassVar[dict[int, _AuthenticatedApi]] = {}
73+
_api_client_external: ClassVar[weakref.WeakKeyDictionary[Callable[[], str], _AuthenticatedApi]] = (
74+
weakref.WeakKeyDictionary()
75+
)
7376

7477
_api: _AuthenticatedApi
7578
applications: Applications
@@ -81,24 +84,14 @@ def __init__(self, cache_token: bool = True, token_provider: Callable[[], str] |
8184
8285
Args:
8386
cache_token: If True, caches the authentication token. Defaults to True.
87+
Ignored when ``token_provider`` is supplied.
8488
token_provider: Optional external token provider callable. When provided,
8589
bypasses internal OAuth authentication entirely. The callable must
86-
return a valid bearer token string.
87-
88-
Raises:
89-
ValueError: If both ``token_provider`` and ``cache_token=False`` are specified,
90-
since token caching is irrelevant when using an external provider.
90+
return a valid bearer token string. When set, ``cache_token`` has no
91+
effect because the external provider manages its own token lifecycle.
9192
9293
Sets up resource accessors for applications, versions, and runs.
9394
"""
94-
if token_provider is not None and not cache_token:
95-
msg = (
96-
"Cannot set cache_token=False with an external token_provider. "
97-
"Token caching is managed internally when using the default OAuth flow. "
98-
"When providing an external token_provider, omit cache_token or use the default."
99-
)
100-
raise ValueError(msg)
101-
10295
try:
10396
logger.trace("Initializing client with cache_token={}, token_provider={}", cache_token, token_provider)
10497
self._api = Client.get_api_client(cache_token=cache_token, token_provider=token_provider)
@@ -260,7 +253,7 @@ def get_api_client(cache_token: bool = True, token_provider: Callable[[], str] |
260253
261254
API client instances are shared across all Client instances for efficient connection reuse.
262255
Three pools are maintained: cached-token, uncached-token, and external-provider (keyed by
263-
provider identity).
256+
provider identity via a WeakKeyDictionary — entries are evicted when the provider is GC'd).
264257
265258
Args:
266259
cache_token: If True, caches the authentication token. Defaults to True.
@@ -275,9 +268,8 @@ def get_api_client(cache_token: bool = True, token_provider: Callable[[], str] |
275268
"""
276269
# Check singleton caches first
277270
if token_provider is not None:
278-
provider_key = id(token_provider)
279-
if provider_key in Client._api_client_external:
280-
return Client._api_client_external[provider_key]
271+
if token_provider in Client._api_client_external:
272+
return Client._api_client_external[token_provider]
281273
elif cache_token and Client._api_client_cached is not None:
282274
return Client._api_client_cached
283275
elif not cache_token and Client._api_client_uncached is not None:
@@ -300,7 +292,7 @@ def get_api_client(cache_token: bool = True, token_provider: Callable[[], str] |
300292

301293
# Store in the appropriate singleton cache
302294
if token_provider is not None:
303-
Client._api_client_external[provider_key] = api_client
295+
Client._api_client_external[token_provider] = api_client
304296
elif cache_token:
305297
Client._api_client_cached = api_client
306298
else:

src/aignostics/platform/resources/applications.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ def __init__(self, api: _AuthenticatedApi) -> None:
7373
api (_AuthenticatedApi): The configured API platform.
7474
"""
7575
self._api = api
76+
# No runtime hasattr check for token_provider: MyPy strict mode (enforced in CI)
77+
# already rejects non-_AuthenticatedApi arguments at static-analysis time.
78+
# A silent getattr fallback is intentionally avoided — it would disable per-user
79+
# cache key isolation in @cached_operation, causing cross-user cache leakage.
7680

7781
def list(self, application: Application | str, nocache: bool = False) -> builtins.list[VersionTuple]:
7882
"""Find all versions for a specific application.
@@ -237,6 +241,10 @@ def __init__(self, api: _AuthenticatedApi) -> None:
237241
api (_AuthenticatedApi): The configured API platform.
238242
"""
239243
self._api = api
244+
# No runtime hasattr check for token_provider: MyPy strict mode (enforced in CI)
245+
# already rejects non-_AuthenticatedApi arguments at static-analysis time.
246+
# A silent getattr fallback is intentionally avoided — it would disable per-user
247+
# cache key isolation in @cached_operation, causing cross-user cache leakage.
240248
self.versions: Versions = Versions(self._api)
241249

242250
def details(self, application_id: str, nocache: bool = False) -> Application:

src/aignostics/platform/resources/runs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ def __init__(self, api: _AuthenticatedApi, run_id: str) -> None:
119119
run_id (str): The ID of the application run.
120120
"""
121121
self._api = api
122+
# No runtime hasattr check for token_provider: MyPy strict mode (enforced in CI)
123+
# already rejects non-_AuthenticatedApi arguments at static-analysis time.
124+
# A silent getattr fallback is intentionally avoided — it would disable per-user
125+
# cache key isolation in @cached_operation, causing cross-user cache leakage.
122126
self.run_id = run_id
123127

124128
@classmethod
@@ -511,6 +515,10 @@ def __init__(self, api: _AuthenticatedApi) -> None:
511515
api (_AuthenticatedApi): The configured API client.
512516
"""
513517
self._api = api
518+
# No runtime hasattr check for token_provider: MyPy strict mode (enforced in CI)
519+
# already rejects non-_AuthenticatedApi arguments at static-analysis time.
520+
# A silent getattr fallback is intentionally avoided — it would disable per-user
521+
# cache key isolation in @cached_operation, causing cross-user cache leakage.
514522

515523
def __call__(self, run_id: str) -> Run:
516524
"""Retrieves an Run instance for an existing run.

tests/aignostics/platform/client_token_provider_test.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,16 @@ def test_external_provider_same_provider_reused() -> None:
157157

158158

159159
@pytest.mark.unit
160-
def test_cache_token_false_with_external_provider_raises() -> None:
161-
"""Test that ValueError is raised when both token_provider and cache_token=False are set."""
162-
with pytest.raises(ValueError, match="Cannot set cache_token=False with an external token_provider"):
160+
def test_cache_token_false_with_external_provider_is_allowed() -> None:
161+
"""Test that cache_token=False is silently ignored when token_provider is set."""
162+
with (
163+
patch("aignostics.platform._client.get_token") as mock_get_token,
164+
patch("aignostics.platform._client.ApiClient"),
165+
patch.object(_AuthenticatedApi, "__init__", lambda self, *a, **kw: None),
166+
):
167+
# Should not raise; cache_token is irrelevant when using an external provider
163168
Client(token_provider=_make_provider("token"), cache_token=False)
169+
mock_get_token.assert_not_called()
164170

165171

166172
@pytest.mark.unit
@@ -172,3 +178,30 @@ def test_cache_token_default_with_external_provider_ok() -> None:
172178
):
173179
# Should not raise
174180
Client(token_provider=_make_provider("token"))
181+
182+
183+
@pytest.mark.unit
184+
def test_falsy_token_provider_logs_warning() -> None:
185+
"""Test that a warning is logged when token_provider returns an empty string."""
186+
empty_provider = _make_provider("")
187+
config = _OAuth2TokenProviderConfiguration(host="https://dummy", token_provider=empty_provider)
188+
189+
with patch("aignostics.platform._api.logger") as mock_logger:
190+
result = config.auth_settings()
191+
192+
assert result == {}
193+
mock_logger.warning.assert_called_once()
194+
warning_msg = mock_logger.warning.call_args[0][0]
195+
assert "empty or None token" in warning_msg
196+
197+
198+
@pytest.mark.unit
199+
def test_none_token_provider_no_warning() -> None:
200+
"""Test that no warning is logged when token_provider is not set (None)."""
201+
config = _OAuth2TokenProviderConfiguration(host="https://dummy")
202+
203+
with patch("aignostics.platform._api.logger") as mock_logger:
204+
result = config.auth_settings()
205+
206+
assert result == {}
207+
mock_logger.warning.assert_not_called()

tests/aignostics/platform/resources/applications_test.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from unittest.mock import Mock
88

99
import pytest
10-
from aignx.codegen.api.public_api import PublicApi
1110
from aignx.codegen.models.application_read_response import ApplicationReadResponse
1211

12+
from aignostics.platform._api import _AuthenticatedApi
1313
from aignostics.platform.resources.applications import Applications, Versions
1414
from aignostics.platform.resources.utils import PAGE_SIZE
1515

@@ -23,12 +23,9 @@ def mock_api() -> Mock:
2323
Returns:
2424
Mock: A mock instance of ExternalsApi.
2525
"""
26-
api = Mock(spec=PublicApi)
27-
# Add token_provider attribute for cache keying (used by CachedApiMixin)
26+
api = Mock(spec=_AuthenticatedApi)
2827
api.token_provider = lambda: "test-token"
29-
# Add api_client attribute (instance attr not in spec) for codegen internals
30-
api_client_mock = Mock()
31-
api.api_client = api_client_mock
28+
api.api_client = Mock()
3229
return api
3330

3431

tests/aignostics/platform/resources/runs_test.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from unittest.mock import Mock
88

99
import pytest
10-
from aignx.codegen.api.public_api import PublicApi
1110
from aignx.codegen.models import (
1211
InputArtifactCreationRequest,
1312
ItemCreationRequest,
@@ -16,6 +15,7 @@
1615
RunReadResponse,
1716
)
1817

18+
from aignostics.platform._api import _AuthenticatedApi
1919
from aignostics.platform.resources.runs import LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, Run, Runs
2020
from aignostics.platform.resources.utils import PAGE_SIZE
2121

@@ -27,12 +27,9 @@ def mock_api() -> Mock:
2727
Returns:
2828
Mock: A mock instance of ExternalsApi.
2929
"""
30-
api = Mock(spec=PublicApi)
31-
# Add token_provider attribute for cache keying (used by CachedApiMixin)
30+
api = Mock(spec=_AuthenticatedApi)
3231
api.token_provider = lambda: "test-token"
33-
# Add api_client attribute (instance attr not in spec) for codegen internals
34-
api_client_mock = Mock()
35-
api.api_client = api_client_mock
32+
api.api_client = Mock()
3633
return api
3734

3835

0 commit comments

Comments
 (0)