diff --git a/.env.example b/.env.example index 4f366df88..fe1a46226 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,8 @@ ATLAN_BASE_URL=your_tenant_base_url + +#API KEY based authentication ATLAN_API_KEY=your_api_key + +#OAuth based authentication +ATLAN_OAUTH_CLIENT_ID=your_oauth_client_id +ATLAN_OAUTH_CLIENT_SECRET=your_oauth_client_secret \ No newline at end of file diff --git a/pyatlan/client/aio/client.py b/pyatlan/client/aio/client.py index d1db68160..c02a218c0 100644 --- a/pyatlan/client/aio/client.py +++ b/pyatlan/client/aio/client.py @@ -16,7 +16,7 @@ from contextlib import _AsyncGeneratorContextManager from http import HTTPStatus from types import SimpleNamespace -from typing import Optional +from typing import Any, Optional import httpx from httpx_retries.retry import Retry @@ -90,6 +90,7 @@ class AsyncAtlanClient(AtlanClient): """ _async_session: Optional[httpx.AsyncClient] = PrivateAttr(default=None) + _async_oauth_token_manager: Optional[Any] = PrivateAttr(default=None) _async_admin_client: Optional[AsyncAdminClient] = PrivateAttr(default=None) _async_asset_client: Optional[AsyncAssetClient] = PrivateAttr(default=None) _async_audit_client: Optional[AsyncAuditClient] = PrivateAttr(default=None) @@ -134,6 +135,34 @@ def __init__(self, **kwargs): # Initialize sync client (handles all validation, env vars, etc.) super().__init__(**kwargs) + if self.oauth_client_id and self.oauth_client_secret and self.api_key is None: + LOGGER.debug( + "API Key not provided. Using Async OAuth flow for authentication" + ) + from pyatlan.client.aio.oauth import AsyncOAuthTokenManager + + if self._oauth_token_manager: + LOGGER.debug("Sync oauth flow open. Closing it for Async oauth flow") + self._oauth_token_manager.close() + self._oauth_token_manager = None + + final_base_url = self.base_url or os.environ.get( + "ATLAN_BASE_URL", "INTERNAL" + ) + final_oauth_client_id = self.oauth_client_id or os.environ.get( + "ATLAN_OAUTH_CLIENT_ID" + ) + final_oauth_client_secret = self.oauth_client_secret or os.environ.get( + "ATLAN_OAUTH_CLIENT_SECRET" + ) + self._async_oauth_token_manager = AsyncOAuthTokenManager( + base_url=final_base_url, + client_id=final_oauth_client_id, + client_secret=final_oauth_client_secret, + connect_timeout=self.connect_timeout, + read_timeout=self.read_timeout, + ) + # Build proxy/SSL configuration (reuse from sync client) transport_kwargs = self._build_transport_proxy_config(kwargs) @@ -438,6 +467,9 @@ async def _create_params( Async version of _create_params that uses AsyncAtlanRequest for AtlanObject instances. """ params = copy.deepcopy(self._request_params) + if self._async_oauth_token_manager: + token = await self._async_oauth_token_manager.get_token() + params["headers"]["authorization"] = f"Bearer {token}" params["headers"]["Accept"] = api.consumes params["headers"]["content-type"] = api.produces if query_params is not None: @@ -687,7 +719,7 @@ async def _handle_error_response( # Retry with impersonation (if _user_id is present) on authentication failure if ( - self._user_id + (self._user_id or self._async_oauth_token_manager) and not self._401_has_retried.get() and response.status_code == ErrorCode.AUTHENTICATION_PASSTHROUGH.http_error_code @@ -746,6 +778,21 @@ async def _handle_401_token_refresh( Async version of token refresh and retry logic. Handles token refresh and retries the API request upon a 401 Unauthorized response. """ + if self._async_oauth_token_manager: + await self._async_oauth_token_manager.invalidate_token() + token = await self._async_oauth_token_manager.get_token() + params["headers"]["authorization"] = f"Bearer {token}" + self._401_has_retried.set(True) + LOGGER.debug("Successfully refreshed OAuth token after 401.") + return await self._call_api_internal( + api, + path, + params, + binary_data=binary_data, + download_file_path=download_file_path, + text_response=text_response, + ) + try: # Use sync impersonation call since it's a quick API call new_token = await self.impersonate.user(user_id=self._user_id) diff --git a/pyatlan/client/aio/oauth.py b/pyatlan/client/aio/oauth.py new file mode 100644 index 000000000..f8cb44119 --- /dev/null +++ b/pyatlan/client/aio/oauth.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 Atlan Pte. Ltd. +import asyncio +import time +from typing import Optional + +import httpx +from authlib.oauth2.rfc6749 import OAuth2Token + +from pyatlan.client.constants import GET_OAUTH_CLIENT +from pyatlan.utils import API + + +class AsyncOAuthTokenManager: + def __init__( + self, + base_url: str, + client_id: str, + client_secret: str, + http_client: Optional[httpx.AsyncClient] = None, + connect_timeout: float = 30.0, + read_timeout: float = 900.0, + ): + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + self.token_url = self._create_path(GET_OAUTH_CLIENT) + self._lock = asyncio.Lock() + self._http_client = http_client or httpx.AsyncClient( + timeout=httpx.Timeout( + connect=connect_timeout, read=read_timeout, write=30.0, pool=30.0 + ) + ) + self._token: Optional[OAuth2Token] = None + self._owns_client = http_client is None + + async def get_token(self) -> str: + async with self._lock: + if self._token and not self._token.is_expired(): + return str(self._token["access_token"]) + + response = await self._http_client.post( + self.token_url, + json={ + "clientId": self.client_id, + "clientSecret": self.client_secret, + }, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + + data = response.json() + access_token = data.get("accessToken") or data.get("access_token") + + if not access_token: + raise ValueError( + f"OAuth token response missing 'accessToken' field. " + f"Response keys: {list(data.keys())}" + ) + + expires_in = data.get("expiresIn") or data.get("expires_in", 600) + + self._token = OAuth2Token( + { + "access_token": access_token, + "token_type": data.get("tokenType") + or data.get("token_type", "Bearer"), + "expires_in": expires_in, + "expires_at": int(time.time()) + expires_in, + } + ) + + return access_token + + async def invalidate_token(self): + async with self._lock: + self._token = None + + def _create_path(self, api: API): + from urllib.parse import urljoin + + if self.base_url == "INTERNAL": + base_with_prefix = urljoin(api.endpoint.service, api.endpoint.prefix) + return urljoin(base_with_prefix, api.path) + else: + base_with_prefix = urljoin(self.base_url, api.endpoint.prefix) + return urljoin(base_with_prefix, api.path) + + async def aclose(self): + if self._owns_client: + await self._http_client.aclose() diff --git a/pyatlan/client/atlan.py b/pyatlan/client/atlan.py index ee5f1bdab..d3c2a59bf 100644 --- a/pyatlan/client/atlan.py +++ b/pyatlan/client/atlan.py @@ -48,6 +48,7 @@ from pyatlan.client.file import FileClient from pyatlan.client.group import GroupClient from pyatlan.client.impersonate import ImpersonationClient +from pyatlan.client.oauth import OAuthTokenManager from pyatlan.client.open_lineage import OpenLineageClient from pyatlan.client.query import QueryClient from pyatlan.client.role import RoleClient @@ -127,7 +128,9 @@ def log_response(response, *args, **kwargs): class AtlanClient(BaseSettings): base_url: Union[Literal["INTERNAL"], HttpUrl] - api_key: str + api_key: Optional[str] = None + oauth_client_id: Optional[str] = None + oauth_client_secret: Optional[str] = None connect_timeout: float = 30.0 # 30 secs read_timeout: float = 900.0 # 15 mins retry: Retry = DEFAULT_RETRY @@ -137,6 +140,7 @@ class AtlanClient(BaseSettings): _session: httpx.Client = PrivateAttr() _request_params: dict = PrivateAttr() _user_id: Optional[str] = PrivateAttr(default=None) + _oauth_token_manager: Optional[Any] = PrivateAttr(default=None) _workflow_client: Optional[WorkflowClient] = PrivateAttr(default=None) _credential_client: Optional[CredentialClient] = PrivateAttr(default=None) _admin_client: Optional[AdminClient] = PrivateAttr(default=None) @@ -172,11 +176,33 @@ class Config: def __init__(self, **data): super().__init__(**data) - self._request_params = ( - {"headers": {"authorization": f"Bearer {self.api_key}"}} - if self.api_key and self.api_key.strip() - else {"headers": {}} - ) + + if self.oauth_client_id and self.oauth_client_secret and self.api_key is None: + LOGGER.debug("API KEY not provided. Using OAuth flow for authentication") + + final_base_url = self.base_url or os.environ.get( + "ATLAN_BASE_URL", "INTERNAL" + ) + final_oauth_client_id = self.oauth_client_id or os.environ.get( + "ATLAN_OAUTH_CLIENT_ID" + ) + final_oauth_client_secret = self.oauth_client_secret or os.environ.get( + "ATLAN_OAUTH_CLIENT_SECRET" + ) + self._oauth_token_manager = OAuthTokenManager( + base_url=final_base_url, + client_id=final_oauth_client_id, + client_secret=final_oauth_client_secret, + connect_timeout=self.connect_timeout, + read_timeout=self.read_timeout, + ) + self._request_params = {"headers": {}} + else: + self._request_params = ( + {"headers": {"authorization": f"Bearer {self.api_key}"}} + if self.api_key and self.api_key.strip() + else {"headers": {}} + ) # Build proxy/SSL configuration with environment variable fallback transport_kwargs = self._build_transport_proxy_config(data) @@ -691,7 +717,7 @@ def _call_api_internal( # Retry with impersonation (if _user_id is present) # on authentication failure (token may have expired) if ( - self._user_id + (self._user_id or self._oauth_token_manager) and not self._401_has_retried.get() and response.status_code == ErrorCode.AUTHENTICATION_PASSTHROUGH.http_error_code @@ -813,6 +839,9 @@ def _create_params( self, api: API, query_params, request_obj, exclude_unset: bool = True ): params = copy.deepcopy(self._request_params) + if self._oauth_token_manager: + token = self._oauth_token_manager.get_token() + params["headers"]["authorization"] = f"Bearer {token}" params["headers"]["Accept"] = api.consumes params["headers"]["content-type"] = api.produces if query_params is not None: @@ -846,6 +875,21 @@ def _handle_401_token_refresh( returns: HTTP response received after retrying the request with the refreshed token """ + if self._oauth_token_manager: + self._oauth_token_manager.invalidate_token() + token = self._oauth_token_manager.get_token() + params["headers"]["authorization"] = f"Bearer {token}" + self._401_has_retried.set(True) + LOGGER.debug("Successfully refreshed OAuth token after 401.") + return self._call_api_internal( + api, + path, + params, + binary_data=binary_data, + download_file_path=download_file_path, + text_response=text_response, + ) + try: new_token = self.impersonate.user(user_id=self._user_id) except Exception as e: diff --git a/pyatlan/client/constants.py b/pyatlan/client/constants.py index 2e847e2d8..76627ed26 100644 --- a/pyatlan/client/constants.py +++ b/pyatlan/client/constants.py @@ -88,6 +88,14 @@ GET_WHOAMI_USER = API( WHOAMI_API, HTTPMethod.GET, HTTPStatus.OK, endpoint=EndPoint.HERACLES ) + +# oauth client authentinatication +GET_OAUTH_CLIENT = API( + "oauth-clients/token", + HTTPMethod.POST, + HTTPStatus.OK, + endpoint=EndPoint.HERACLES, +) # SQL parsing APIs PARSE_QUERY = API( f"{QUERY_API}/parse", HTTPMethod.POST, HTTPStatus.OK, endpoint=EndPoint.HEKA diff --git a/pyatlan/client/oauth.py b/pyatlan/client/oauth.py new file mode 100644 index 000000000..a11037069 --- /dev/null +++ b/pyatlan/client/oauth.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 Atlan Pte. Ltd. +import threading +import time +from typing import Optional + +import httpx +from authlib.oauth2.rfc6749 import OAuth2Token + +from pyatlan.client.constants import GET_OAUTH_CLIENT +from pyatlan.utils import API + + +class OAuthTokenManager: + def __init__( + self, + base_url: str, + client_id: str, + client_secret: str, + http_client: Optional[httpx.Client] = None, + connect_timeout: float = 30.0, + read_timeout: float = 900.0, + ): + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + self.token_url = self._create_path(GET_OAUTH_CLIENT) + self._lock = threading.Lock() + self._http_client = http_client or httpx.Client( + timeout=httpx.Timeout( + connect=connect_timeout, read=read_timeout, write=30.0, pool=30.0 + ) + ) + self._token: Optional[OAuth2Token] = None + self._owns_client = http_client is None + + def get_token(self) -> str: + with self._lock: + if self._token and not self._token.is_expired(): + return str(self._token["access_token"]) + + response = self._http_client.post( + self.token_url, + json={ + "clientId": self.client_id, + "clientSecret": self.client_secret, + }, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + + data = response.json() + access_token = data.get("accessToken") or data.get("access_token") + + if not access_token: + raise ValueError( + f"OAuth token response missing 'accessToken' field. " + f"Response keys: {list(data.keys())}" + ) + + expires_in = data.get("expiresIn") or data.get("expires_in", 600) + + self._token = OAuth2Token( + { + "access_token": access_token, + "token_type": data.get("tokenType") + or data.get("token_type", "Bearer"), + "expires_in": expires_in, + "expires_at": int(time.time()) + expires_in, + } + ) + + return access_token + + def invalidate_token(self): + with self._lock: + self._token = None + + def _create_path(self, api: API): + from urllib.parse import urljoin + + if self.base_url == "INTERNAL": + base_with_prefix = urljoin(api.endpoint.service, api.endpoint.prefix) + return urljoin(base_with_prefix, api.path) + else: + base_with_prefix = urljoin(self.base_url, api.endpoint.prefix) + return urljoin(base_with_prefix, api.path) + + def close(self): + if self._owns_client: + self._http_client.close() diff --git a/pyatlan/pkg/utils.py b/pyatlan/pkg/utils.py index 42acfdd92..f9e05fe58 100644 --- a/pyatlan/pkg/utils.py +++ b/pyatlan/pkg/utils.py @@ -69,7 +69,7 @@ def _is_valid_type(self, value: Any) -> bool: def get_client( - impersonate_user_id: str, set_pkg_headers: Optional[bool] = False + impersonate_user_id: Optional[str] = None, set_pkg_headers: Optional[bool] = False ) -> AtlanClient: """ Set up the default Atlan client, based on environment variables. @@ -83,6 +83,27 @@ def get_client( base_url = os.environ.get("ATLAN_BASE_URL", "INTERNAL") api_token = os.environ.get("ATLAN_API_KEY", "") user_id = os.environ.get("ATLAN_USER_ID", impersonate_user_id) + oauth_client_id = os.environ.get("ATLAN_OAUTH_CLIENT_ID", "") + oauth_client_secret = os.environ.get("ATLAN_OAUTH_CLIENT_SECRET", "") + + print(oauth_client_id) + print(oauth_client_secret) + print(type(oauth_client_id)) + print(type(oauth_client_secret)) + if oauth_client_id and oauth_client_secret: + LOGGER.info("Using OAuth client credentials for authentication.") + client = AtlanClient( + base_url=base_url, + oauth_client_id=str(oauth_client_id), + oauth_client_secret=str(oauth_client_secret), + ) + if set_pkg_headers: + client = set_package_headers(client) + return client + else: + LOGGER.info( + "No OAuth client credentials found. Attempting to use API token or user impersonation." + ) if api_token: LOGGER.info("Using provided API token for authentication.") @@ -107,7 +128,7 @@ def get_client( async def get_client_async( - impersonate_user_id: str, set_pkg_headers: Optional[bool] = False + impersonate_user_id: Optional[str] = None, set_pkg_headers: Optional[bool] = False ) -> AsyncAtlanClient: """ Set up the default async Atlan client, based on environment variables. @@ -121,6 +142,23 @@ async def get_client_async( base_url = os.environ.get("ATLAN_BASE_URL", "INTERNAL") api_token = os.environ.get("ATLAN_API_KEY", "") user_id = os.environ.get("ATLAN_USER_ID", impersonate_user_id) + oauth_client_id = os.environ.get("ATLAN_OAUTH_CLIENT_ID", "") + oauth_client_secret = os.environ.get("ATLAN_OAUTH_CLIENT_SECRET", "") + + if oauth_client_id and oauth_client_secret: + LOGGER.info("Using Async OAuth client credentials for authentication.") + client = AsyncAtlanClient( + base_url=base_url, + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + ) + if set_pkg_headers: + client = set_package_headers(client) + return client + else: + LOGGER.info( + "No OAuth client credentials found. Attempting to use API token or user impersonation." + ) if api_token: LOGGER.info("Using provided API token for authentication.") diff --git a/pyatlan/utils.py b/pyatlan/utils.py index 7c38bc823..c73964c02 100644 --- a/pyatlan/utils.py +++ b/pyatlan/utils.py @@ -185,7 +185,7 @@ class EndPoint(EndpointMixin, Enum): ) HEKA = "api/sql/", "http://heka-service.heka.svc.cluster.local/" IMPERSONATION = "", "http://keycloak-http.keycloak.svc.cluster.local/" - HERACLES = "api/service/", "http://heracles-service.heracles.svc.cluster.local/" + HERACLES = "api/service/", "https://heracles-service.heracles.svc.cluster.local/" CHRONOS = "events/openlineage/", "http://chronos-service.kong.svc.cluster.local/" diff --git a/pyproject.toml b/pyproject.toml index eb64cb13d..368813cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "PyYAML~=6.0.3", "httpx~=0.28.1", "httpx-retries~=0.4.5", + "authlib~=1.6.5", ] [project.urls] @@ -50,6 +51,7 @@ dev = [ "mypy~=1.18.0", "ruff~=0.14.0", "types-setuptools~=80.9.0.20250822", + "types-Authlib", "pytest~=8.4.2", "pytest-vcr~=1.0.2", "vcrpy~=7.0.0", diff --git a/tests/unit/aio/test_oauth_client.py b/tests/unit/aio/test_oauth_client.py new file mode 100644 index 000000000..c6e4ac27c --- /dev/null +++ b/tests/unit/aio/test_oauth_client.py @@ -0,0 +1,781 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 Atlan Pte. Ltd. +""" +Comprehensive Async OAuth Client Tests + +Tests for OAuth authentication in the asynchronous AsyncAtlanClient: +- Authentication method precedence +- Environment variable handling +- Token lifecycle (fetch, cache, refresh, expiry) +- Error handling and edge cases +- Async concurrency safety +- Resource cleanup +- URL construction +- Sync manager cleanup (resource leak prevention) +""" + +import asyncio +import os +from unittest.mock import Mock, patch +from urllib.parse import urlparse + +import httpx +import pytest + +from pyatlan.client.aio.client import AsyncAtlanClient +from pyatlan.client.aio.oauth import AsyncOAuthTokenManager +from pyatlan.client.oauth import OAuthTokenManager + + +@pytest.fixture +def clear_env_vars(): + """Clear OAuth-related environment variables before each test""" + env_vars = [ + "ATLAN_BASE_URL", + "ATLAN_API_KEY", + "ATLAN_OAUTH_CLIENT_ID", + "ATLAN_OAUTH_CLIENT_SECRET", + ] + original_values = {} + for var in env_vars: + original_values[var] = os.environ.get(var) + if var in os.environ: + del os.environ[var] + + yield + + for var, value in original_values.items(): + if value is not None: + os.environ[var] = value + elif var in os.environ: + del os.environ[var] + + +@pytest.fixture +def mock_oauth_response(): + """Mock successful OAuth token response with camelCase""" + return { + "accessToken": "async-test-access-token-12345", + "tokenType": "Bearer", + "expiresIn": 3600, + } + + +@pytest.fixture +def mock_oauth_response_snake_case(): + """Mock successful OAuth token response with snake_case""" + return { + "access_token": "async-test-access-token-67890", + "token_type": "Bearer", + "expires_in": 3600, + } + + +class TestAsyncOAuthTokenManagerInit: + """Test async OAuth token manager initialization""" + + @pytest.mark.asyncio + async def test_init_with_external_url(self, clear_env_vars): + """Initialize with external URL""" + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert manager.base_url == "https://test.atlan.com" + assert manager.client_id == "test-client-id" + assert manager.client_secret == "test-client-secret" + assert ( + manager.token_url + == "https://test.atlan.com/api/service/oauth-clients/token" + ) + assert manager._token is None + assert manager._owns_client is True + + await manager.aclose() + + @pytest.mark.asyncio + async def test_init_with_internal_url(self, clear_env_vars): + """Initialize with INTERNAL mode""" + manager = AsyncOAuthTokenManager( + base_url="INTERNAL", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert manager.base_url == "INTERNAL" + expected_url = "http://heracles-service.heracles.svc.cluster.local/api/service/oauth-clients/token" + assert manager.token_url == expected_url + + await manager.aclose() + + @pytest.mark.asyncio + async def test_init_with_external_http_client(self, clear_env_vars): + """Initialize with externally provided async HTTP client""" + external_client = httpx.AsyncClient() + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + http_client=external_client, + ) + + assert manager._http_client is external_client + assert manager._owns_client is False + + await manager.aclose() + assert not external_client.is_closed + await external_client.aclose() + + @pytest.mark.asyncio + async def test_init_creates_http_client_when_not_provided(self, clear_env_vars): + """Initialize without HTTP client should create one""" + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert manager._http_client is not None + assert isinstance(manager._http_client, httpx.AsyncClient) + assert manager._owns_client is True + + await manager.aclose() + + +class TestTokenFetchingAndCaching: + """Test token fetching and caching behavior""" + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_first_token_fetch( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """First call should fetch token from API""" + mock_response = Mock() + mock_response.json = Mock(return_value=mock_oauth_response) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token = await manager.get_token() + + assert token == "async-test-access-token-12345" + assert mock_post.call_count == 1 + + call_args = mock_post.call_args + assert call_args[1]["json"]["clientId"] == "test-client-id" + assert call_args[1]["json"]["clientSecret"] == "test-client-secret" + assert call_args[1]["headers"]["Content-Type"] == "application/json" + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_token_caching(self, mock_post, clear_env_vars, mock_oauth_response): + """Subsequent calls should use cached token""" + mock_response = Mock() + mock_response.json = Mock(return_value=mock_oauth_response) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token1 = await manager.get_token() + assert token1 == "async-test-access-token-12345" + assert mock_post.call_count == 1 + + token2 = await manager.get_token() + assert token2 == "async-test-access-token-12345" + assert mock_post.call_count == 1 + + token3 = await manager.get_token() + assert token3 == "async-test-access-token-12345" + assert mock_post.call_count == 1 + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_snake_case_response( + self, mock_post, clear_env_vars, mock_oauth_response_snake_case + ): + """Should handle snake_case field names in response""" + mock_response = Mock() + mock_response.json = Mock(return_value=mock_oauth_response_snake_case) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token = await manager.get_token() + assert token == "async-test-access-token-67890" + + await manager.aclose() + + +class TestTokenExpiryAndRefresh: + """Test token expiry detection and automatic refresh""" + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_token_refresh_on_expiry(self, mock_post, clear_env_vars): + """Expired token should trigger automatic refresh""" + first_response = Mock() + first_response.json = Mock( + return_value={ + "accessToken": "async-token-1", + "tokenType": "Bearer", + "expiresIn": 1, + } + ) + first_response.raise_for_status = Mock() + + second_response = Mock() + second_response.json = Mock( + return_value={ + "accessToken": "async-token-2", + "tokenType": "Bearer", + "expiresIn": 3600, + } + ) + second_response.raise_for_status = Mock() + + mock_post.side_effect = [first_response, second_response] + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token1 = await manager.get_token() + assert token1 == "async-token-1" + assert mock_post.call_count == 1 + + await asyncio.sleep(2) + + token2 = await manager.get_token() + assert token2 == "async-token-2" + assert mock_post.call_count == 2 + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_manual_token_invalidation( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """Manual invalidation should force refresh on next call""" + mock_response = Mock() + mock_response.json = Mock(return_value=mock_oauth_response) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + await manager.get_token() + assert mock_post.call_count == 1 + + await manager.invalidate_token() + assert manager._token is None + + await manager.get_token() + assert mock_post.call_count == 2 + + await manager.aclose() + + +class TestErrorHandling: + """Test error handling in various failure scenarios""" + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_missing_access_token(self, mock_post, clear_env_vars): + """Missing accessToken should raise ValueError""" + mock_response = Mock() + mock_response.json = Mock( + return_value={ + "tokenType": "Bearer", + "expiresIn": 3600, + } + ) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises( + ValueError, match="OAuth token response missing 'accessToken' field" + ): + await manager.get_token() + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_http_401_error(self, mock_post, clear_env_vars): + """401 error should be propagated""" + mock_response = Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "401 Unauthorized", + request=Mock(), + response=Mock(status_code=401), + ) + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.HTTPStatusError): + await manager.get_token() + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_http_500_error(self, mock_post, clear_env_vars): + """500 error should be propagated""" + mock_response = Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500 Internal Server Error", + request=Mock(), + response=Mock(status_code=500), + ) + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.HTTPStatusError): + await manager.get_token() + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_network_error(self, mock_post, clear_env_vars): + """Network errors should be propagated""" + mock_post.side_effect = httpx.ConnectError("Connection refused") + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.ConnectError): + await manager.get_token() + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_timeout_error(self, mock_post, clear_env_vars): + """Timeout errors should be propagated""" + mock_post.side_effect = httpx.TimeoutException("Request timeout") + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.TimeoutException): + await manager.get_token() + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_invalid_json_response(self, mock_post, clear_env_vars): + """Invalid JSON in response should raise error""" + mock_response = Mock() + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(ValueError, match="Invalid JSON"): + await manager.get_token() + + await manager.aclose() + + +class TestAsyncConcurrencySafety: + """Test async concurrency safety of OAuth token management""" + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_concurrent_token_requests( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """Multiple coroutines requesting token simultaneously should result in single fetch""" + call_count = {"count": 0} + + async def mock_post_with_delay(*args, **kwargs): + call_count["count"] += 1 + await asyncio.sleep(0.1) + mock_response = Mock() + mock_response.json = Mock(return_value=mock_oauth_response) + mock_response.raise_for_status = Mock() + return mock_response + + mock_post.side_effect = mock_post_with_delay + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + tokens = await asyncio.gather(*[manager.get_token() for _ in range(10)]) + + assert len(tokens) == 10 + assert all(token == tokens[0] for token in tokens) + + assert call_count["count"] <= 2 + + await manager.aclose() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_concurrent_invalidation_and_fetch( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """Concurrent invalidation and fetch should be async-safe""" + mock_response = Mock() + mock_response.json = Mock(return_value=mock_oauth_response) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + await manager.get_token() + + async def invalidate_repeatedly(): + for _ in range(5): + await manager.invalidate_token() + await asyncio.sleep(0.01) + + async def fetch_repeatedly(): + for _ in range(5): + await manager.get_token() + await asyncio.sleep(0.01) + + await asyncio.gather( + invalidate_repeatedly(), + fetch_repeatedly(), + fetch_repeatedly(), + ) + + await manager.aclose() + + +class TestResourceCleanup: + """Test proper cleanup of resources""" + + @pytest.mark.asyncio + async def test_close_http_client(self, clear_env_vars): + """aclose should close owned HTTP client""" + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + http_client = manager._http_client + assert not http_client.is_closed + + await manager.aclose() + assert http_client.is_closed + + @pytest.mark.asyncio + async def test_dont_close_external_client(self, clear_env_vars): + """Should not close externally provided HTTP client""" + external_client = httpx.AsyncClient() + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + http_client=external_client, + ) + + await manager.aclose() + + assert not external_client.is_closed + + await external_client.aclose() + + +class TestAsyncAtlanClientAuthPrecedence: + """Test authentication method precedence in AsyncAtlanClient""" + + @pytest.mark.asyncio + async def test_api_key_only(self, clear_env_vars): + """API key authentication""" + client = AsyncAtlanClient( + base_url="https://test.atlan.com", + api_key="test-api-key", + ) + + assert client.api_key == "test-api-key" + assert client._async_oauth_token_manager is None + assert client._oauth_token_manager is None + assert "authorization" in client._request_params["headers"] + assert ( + client._request_params["headers"]["authorization"] == "Bearer test-api-key" + ) + + await client.aclose() + + @pytest.mark.asyncio + async def test_oauth_only(self, clear_env_vars): + """OAuth authentication""" + client = AsyncAtlanClient( + base_url="https://test.atlan.com", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client.api_key is None + assert client._async_oauth_token_manager is not None + assert client._oauth_token_manager is None + assert client._async_oauth_token_manager.client_id == "test-client-id" + assert client._async_oauth_token_manager.client_secret == "test-client-secret" + + await client.aclose() + + @pytest.mark.asyncio + async def test_api_key_takes_precedence(self, clear_env_vars): + """API key takes precedence when both provided""" + client = AsyncAtlanClient( + base_url="https://test.atlan.com", + api_key="test-api-key", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client.api_key == "test-api-key" + assert client._async_oauth_token_manager is None + assert client._oauth_token_manager is None + assert ( + client._request_params["headers"]["authorization"] == "Bearer test-api-key" + ) + + await client.aclose() + + @pytest.mark.asyncio + async def test_empty_api_key(self, clear_env_vars): + """Empty API key should not create OAuth manager""" + client = AsyncAtlanClient( + base_url="https://test.atlan.com", + api_key="", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client.api_key == "" + assert client._async_oauth_token_manager is None + assert "authorization" not in client._request_params["headers"] + + await client.aclose() + + @pytest.mark.asyncio + async def test_no_authentication(self, clear_env_vars): + """No authentication provided""" + client = AsyncAtlanClient(base_url="https://test.atlan.com") + + assert client.api_key is None + assert client._async_oauth_token_manager is None + assert "authorization" not in client._request_params["headers"] + + await client.aclose() + + +class TestSyncManagerCleanup: + """Test that sync OAuth manager is properly closed to prevent resource leaks""" + + @pytest.mark.asyncio + async def test_sync_manager_closed_on_oauth_init(self, clear_env_vars): + """Sync OAuth manager should be closed when async client uses OAuth""" + with patch.object(OAuthTokenManager, "close") as mock_close: + client = AsyncAtlanClient( + base_url="https://test.atlan.com", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert mock_close.called or client._oauth_token_manager is None + assert client._async_oauth_token_manager is not None + + await client.aclose() + + @pytest.mark.asyncio + async def test_no_sync_manager_leak(self, clear_env_vars): + """Verify no sync OAuth manager remains after async client initialization""" + client = AsyncAtlanClient( + base_url="https://test.atlan.com", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client._oauth_token_manager is None + assert client._async_oauth_token_manager is not None + + await client.aclose() + + +class TestEnvironmentVariables: + """Test environment variable handling""" + + @pytest.mark.asyncio + async def test_oauth_from_env(self, clear_env_vars): + """OAuth credentials from environment variables""" + os.environ["ATLAN_BASE_URL"] = "https://env.atlan.com" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + os.environ["ATLAN_OAUTH_CLIENT_SECRET"] = "env-client-secret" + + client = AsyncAtlanClient() + + assert client._async_oauth_token_manager is not None + assert client._async_oauth_token_manager.client_id == "env-client-id" + assert client._async_oauth_token_manager.client_secret == "env-client-secret" + + await client.aclose() + + @pytest.mark.asyncio + async def test_explicit_overrides_env(self, clear_env_vars): + """Explicit parameters override environment variables""" + os.environ["ATLAN_BASE_URL"] = "https://env.atlan.com" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + os.environ["ATLAN_OAUTH_CLIENT_SECRET"] = "env-client-secret" + + client = AsyncAtlanClient( + base_url="https://explicit.atlan.com", + oauth_client_id="explicit-client-id", + oauth_client_secret="explicit-client-secret", + ) + + assert urlparse(str(client.base_url)).hostname == "explicit.atlan.com" + assert client._async_oauth_token_manager.client_id == "explicit-client-id" + assert ( + client._async_oauth_token_manager.client_secret == "explicit-client-secret" + ) + + await client.aclose() + + @pytest.mark.asyncio + async def test_api_key_env_precedence(self, clear_env_vars): + """API key from env takes precedence over OAuth""" + os.environ["ATLAN_BASE_URL"] = "https://test.atlan.com" + os.environ["ATLAN_API_KEY"] = "env-api-key" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + os.environ["ATLAN_OAUTH_CLIENT_SECRET"] = "env-client-secret" + + client = AsyncAtlanClient() + + assert client.api_key == "env-api-key" + assert client._async_oauth_token_manager is None + assert ( + client._request_params["headers"]["authorization"] == "Bearer env-api-key" + ) + + await client.aclose() + + @pytest.mark.asyncio + async def test_partial_oauth_credentials(self, clear_env_vars): + """Partial OAuth credentials should not create manager""" + os.environ["ATLAN_BASE_URL"] = "https://test.atlan.com" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + + client = AsyncAtlanClient() + + assert client._async_oauth_token_manager is None + + await client.aclose() + + +class TestEdgeCases: + """Test edge cases and unusual scenarios""" + + @pytest.mark.asyncio + @patch("httpx.AsyncClient.post") + async def test_default_expires_in(self, mock_post, clear_env_vars): + """Missing expiresIn should use default""" + mock_response = Mock() + mock_response.json = Mock( + return_value={ + "accessToken": "test-token", + "tokenType": "Bearer", + } + ) + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token = await manager.get_token() + assert token == "test-token" + + await manager.aclose() + + @pytest.mark.asyncio + async def test_url_with_trailing_slash(self, clear_env_vars): + """Base URL with trailing slash""" + manager = AsyncOAuthTokenManager( + base_url="https://test.atlan.com/", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert "/api/service/oauth-clients/token" in manager.token_url + + await manager.aclose() + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/tests/unit/test_oauth_client.py b/tests/unit/test_oauth_client.py new file mode 100644 index 000000000..409181045 --- /dev/null +++ b/tests/unit/test_oauth_client.py @@ -0,0 +1,706 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 Atlan Pte. Ltd. +""" +Comprehensive Sync OAuth Client Tests + +Tests for OAuth authentication in the synchronous AtlanClient: +- Authentication method precedence +- Environment variable handling +- Token lifecycle (fetch, cache, refresh, expiry) +- Error handling and edge cases +- Thread safety +- Resource cleanup +- URL construction +""" + +import os +import threading +import time +from unittest.mock import Mock, patch +from urllib.parse import urlparse + +import httpx +import pytest + +from pyatlan.client.atlan import AtlanClient +from pyatlan.client.oauth import OAuthTokenManager + + +@pytest.fixture +def clear_env_vars(): + """Clear OAuth-related environment variables before each test""" + env_vars = [ + "ATLAN_BASE_URL", + "ATLAN_API_KEY", + "ATLAN_OAUTH_CLIENT_ID", + "ATLAN_OAUTH_CLIENT_SECRET", + ] + original_values = {} + for var in env_vars: + original_values[var] = os.environ.get(var) + if var in os.environ: + del os.environ[var] + + yield + + for var, value in original_values.items(): + if value is not None: + os.environ[var] = value + elif var in os.environ: + del os.environ[var] + + +@pytest.fixture +def mock_oauth_response(): + """Mock successful OAuth token response with camelCase""" + return { + "accessToken": "test-access-token-12345", + "tokenType": "Bearer", + "expiresIn": 600, + } + + +@pytest.fixture +def mock_oauth_response_snake_case(): + """Mock successful OAuth token response with snake_case""" + return { + "access_token": "test-access-token-67890", + "token_type": "Bearer", + "expires_in": 600, + } + + +class TestOAuthTokenManagerInit: + """Test OAuth token manager initialization""" + + def test_init_with_external_url(self, clear_env_vars): + """Initialize with external URL""" + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert manager.base_url == "https://test.atlan.com" + assert manager.client_id == "test-client-id" + assert manager.client_secret == "test-client-secret" + assert ( + manager.token_url + == "https://test.atlan.com/api/service/oauth-clients/token" + ) + assert manager._token is None + assert manager._owns_client is True + + manager.close() + + def test_init_with_internal_url(self, clear_env_vars): + """Initialize with INTERNAL mode""" + manager = OAuthTokenManager( + base_url="INTERNAL", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert manager.base_url == "INTERNAL" + expected_url = "http://heracles-service.heracles.svc.cluster.local/api/service/oauth-clients/token" + assert manager.token_url == expected_url + + manager.close() + + def test_init_with_external_http_client(self, clear_env_vars): + """Initialize with externally provided HTTP client""" + external_client = httpx.Client() + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + http_client=external_client, + ) + + assert manager._http_client is external_client + assert manager._owns_client is False + + manager.close() + assert not external_client.is_closed + external_client.close() + + def test_init_creates_http_client_when_not_provided(self, clear_env_vars): + """Initialize without HTTP client should create one""" + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert manager._http_client is not None + assert isinstance(manager._http_client, httpx.Client) + assert manager._owns_client is True + + manager.close() + + +class TestTokenFetchingAndCaching: + """Test token fetching and caching behavior""" + + @patch("httpx.Client.post") + def test_first_token_fetch(self, mock_post, clear_env_vars, mock_oauth_response): + """First call should fetch token from API""" + mock_response = Mock() + mock_response.json.return_value = mock_oauth_response + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token = manager.get_token() + + assert token == "test-access-token-12345" + assert mock_post.call_count == 1 + + call_args = mock_post.call_args + assert call_args[1]["json"]["clientId"] == "test-client-id" + assert call_args[1]["json"]["clientSecret"] == "test-client-secret" + assert call_args[1]["headers"]["Content-Type"] == "application/json" + + manager.close() + + @patch("httpx.Client.post") + def test_token_caching(self, mock_post, clear_env_vars, mock_oauth_response): + """Subsequent calls should use cached token""" + mock_response = Mock() + mock_response.json.return_value = mock_oauth_response + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token1 = manager.get_token() + assert token1 == "test-access-token-12345" + assert mock_post.call_count == 1 + + token2 = manager.get_token() + assert token2 == "test-access-token-12345" + assert mock_post.call_count == 1 + + token3 = manager.get_token() + assert token3 == "test-access-token-12345" + assert mock_post.call_count == 1 + + manager.close() + + @patch("httpx.Client.post") + def test_snake_case_response( + self, mock_post, clear_env_vars, mock_oauth_response_snake_case + ): + """Should handle snake_case field names in response""" + mock_response = Mock() + mock_response.json.return_value = mock_oauth_response_snake_case + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token = manager.get_token() + assert token == "test-access-token-67890" + + manager.close() + + +class TestTokenExpiryAndRefresh: + """Test token expiry detection and automatic refresh""" + + @patch("httpx.Client.post") + def test_token_refresh_on_expiry(self, mock_post, clear_env_vars): + """Expired token should trigger automatic refresh""" + first_response = Mock() + first_response.json.return_value = { + "accessToken": "token-1", + "tokenType": "Bearer", + "expiresIn": 1, + } + first_response.raise_for_status = Mock() + + second_response = Mock() + second_response.json.return_value = { + "accessToken": "token-2", + "tokenType": "Bearer", + "expiresIn": 600, + } + second_response.raise_for_status = Mock() + + mock_post.side_effect = [first_response, second_response] + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token1 = manager.get_token() + assert token1 == "token-1" + assert mock_post.call_count == 1 + + time.sleep(2) + + token2 = manager.get_token() + assert token2 == "token-2" + assert mock_post.call_count == 2 + + manager.close() + + @patch("httpx.Client.post") + def test_manual_token_invalidation( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """Manual invalidation should force refresh on next call""" + mock_response = Mock() + mock_response.json.return_value = mock_oauth_response + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + manager.get_token() + assert mock_post.call_count == 1 + + manager.invalidate_token() + assert manager._token is None + + manager.get_token() + assert mock_post.call_count == 2 + + manager.close() + + +class TestErrorHandling: + """Test error handling in various failure scenarios""" + + @patch("httpx.Client.post") + def test_missing_access_token(self, mock_post, clear_env_vars): + """Missing accessToken should raise ValueError""" + mock_response = Mock() + mock_response.json.return_value = { + "tokenType": "Bearer", + "expiresIn": 600, + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises( + ValueError, match="OAuth token response missing 'accessToken' field" + ): + manager.get_token() + + manager.close() + + @patch("httpx.Client.post") + def test_http_401_error(self, mock_post, clear_env_vars): + """401 error should be propagated""" + mock_response = Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "401 Unauthorized", + request=Mock(), + response=Mock(status_code=401), + ) + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.HTTPStatusError): + manager.get_token() + + manager.close() + + @patch("httpx.Client.post") + def test_http_500_error(self, mock_post, clear_env_vars): + """500 error should be propagated""" + mock_response = Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500 Internal Server Error", + request=Mock(), + response=Mock(status_code=500), + ) + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.HTTPStatusError): + manager.get_token() + + manager.close() + + @patch("httpx.Client.post") + def test_network_error(self, mock_post, clear_env_vars): + """Network errors should be propagated""" + mock_post.side_effect = httpx.ConnectError("Connection refused") + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.ConnectError): + manager.get_token() + + manager.close() + + @patch("httpx.Client.post") + def test_timeout_error(self, mock_post, clear_env_vars): + """Timeout errors should be propagated""" + mock_post.side_effect = httpx.TimeoutException("Request timeout") + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(httpx.TimeoutException): + manager.get_token() + + manager.close() + + @patch("httpx.Client.post") + def test_invalid_json_response(self, mock_post, clear_env_vars): + """Invalid JSON in response should raise error""" + mock_response = Mock() + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with pytest.raises(ValueError, match="Invalid JSON"): + manager.get_token() + + manager.close() + + +class TestThreadSafety: + """Test thread safety of OAuth token management""" + + @patch("httpx.Client.post") + def test_concurrent_token_requests( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """Multiple threads requesting token simultaneously should result in single fetch""" + call_count = {"count": 0} + lock = threading.Lock() + + def mock_post_with_delay(*args, **kwargs): + with lock: + call_count["count"] += 1 + time.sleep(0.1) + mock_response = Mock() + mock_response.json.return_value = mock_oauth_response + mock_response.raise_for_status = Mock() + return mock_response + + mock_post.side_effect = mock_post_with_delay + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + tokens = [] + + def get_token(): + token = manager.get_token() + tokens.append(token) + + threads = [threading.Thread(target=get_token) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(tokens) == 10 + assert all(token == tokens[0] for token in tokens) + + assert call_count["count"] <= 2 + + manager.close() + + @patch("httpx.Client.post") + def test_concurrent_invalidation_and_fetch( + self, mock_post, clear_env_vars, mock_oauth_response + ): + """Concurrent invalidation and fetch should be thread-safe""" + mock_response = Mock() + mock_response.json.return_value = mock_oauth_response + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + manager.get_token() + + def invalidate_repeatedly(): + for _ in range(5): + manager.invalidate_token() + time.sleep(0.01) + + def fetch_repeatedly(): + for _ in range(5): + manager.get_token() + time.sleep(0.01) + + threads = [ + threading.Thread(target=invalidate_repeatedly), + threading.Thread(target=fetch_repeatedly), + threading.Thread(target=fetch_repeatedly), + ] + for t in threads: + t.start() + for t in threads: + t.join() + + manager.close() + + +class TestResourceCleanup: + """Test proper cleanup of resources""" + + def test_close_http_client(self, clear_env_vars): + """Close should close owned HTTP client""" + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + http_client = manager._http_client + assert not http_client.is_closed + + manager.close() + assert http_client.is_closed + + def test_dont_close_external_client(self, clear_env_vars): + """Should not close externally provided HTTP client""" + external_client = httpx.Client() + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + http_client=external_client, + ) + + manager.close() + + assert not external_client.is_closed + + external_client.close() + + +class TestAtlanClientAuthPrecedence: + """Test authentication method precedence in AtlanClient""" + + def test_api_key_only(self, clear_env_vars): + """API key authentication""" + client = AtlanClient( + base_url="https://test.atlan.com", + api_key="test-api-key", + ) + + assert client.api_key == "test-api-key" + assert client._oauth_token_manager is None + assert "authorization" in client._request_params["headers"] + assert ( + client._request_params["headers"]["authorization"] == "Bearer test-api-key" + ) + + def test_oauth_only(self, clear_env_vars): + """OAuth authentication""" + client = AtlanClient( + base_url="https://test.atlan.com", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client.api_key is None + assert client._oauth_token_manager is not None + assert client._oauth_token_manager.client_id == "test-client-id" + assert client._oauth_token_manager.client_secret == "test-client-secret" + + client._oauth_token_manager.close() + + def test_api_key_takes_precedence(self, clear_env_vars): + """API key takes precedence when both provided""" + client = AtlanClient( + base_url="https://test.atlan.com", + api_key="test-api-key", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client.api_key == "test-api-key" + assert client._oauth_token_manager is None + assert ( + client._request_params["headers"]["authorization"] == "Bearer test-api-key" + ) + + def test_empty_api_key(self, clear_env_vars): + """Empty API key should not create OAuth manager""" + client = AtlanClient( + base_url="https://test.atlan.com", + api_key="", + oauth_client_id="test-client-id", + oauth_client_secret="test-client-secret", + ) + + assert client.api_key == "" + assert client._oauth_token_manager is None + assert "authorization" not in client._request_params["headers"] + + def test_no_authentication(self, clear_env_vars): + """No authentication provided""" + client = AtlanClient(base_url="https://test.atlan.com") + + assert client.api_key is None + assert client._oauth_token_manager is None + assert "authorization" not in client._request_params["headers"] + + +class TestEnvironmentVariables: + """Test environment variable handling""" + + def test_oauth_from_env(self, clear_env_vars): + """OAuth credentials from environment variables""" + os.environ["ATLAN_BASE_URL"] = "https://env.atlan.com" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + os.environ["ATLAN_OAUTH_CLIENT_SECRET"] = "env-client-secret" + + client = AtlanClient() + + assert client._oauth_token_manager is not None + assert client._oauth_token_manager.client_id == "env-client-id" + assert client._oauth_token_manager.client_secret == "env-client-secret" + + client._oauth_token_manager.close() + + def test_explicit_overrides_env(self, clear_env_vars): + """Explicit parameters override environment variables""" + os.environ["ATLAN_BASE_URL"] = "https://env.atlan.com" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + os.environ["ATLAN_OAUTH_CLIENT_SECRET"] = "env-client-secret" + + client = AtlanClient( + base_url="https://explicit.atlan.com", + oauth_client_id="explicit-client-id", + oauth_client_secret="explicit-client-secret", + ) + + assert urlparse(str(client.base_url)).hostname == "explicit.atlan.com" + assert client._oauth_token_manager.client_id == "explicit-client-id" + assert client._oauth_token_manager.client_secret == "explicit-client-secret" + + client._oauth_token_manager.close() + + def test_api_key_env_precedence(self, clear_env_vars): + """API key from env takes precedence over OAuth""" + os.environ["ATLAN_BASE_URL"] = "https://test.atlan.com" + os.environ["ATLAN_API_KEY"] = "env-api-key" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + os.environ["ATLAN_OAUTH_CLIENT_SECRET"] = "env-client-secret" + + client = AtlanClient() + + assert client.api_key == "env-api-key" + assert client._oauth_token_manager is None + assert ( + client._request_params["headers"]["authorization"] == "Bearer env-api-key" + ) + + def test_partial_oauth_credentials(self, clear_env_vars): + """Partial OAuth credentials should not create manager""" + os.environ["ATLAN_BASE_URL"] = "https://test.atlan.com" + os.environ["ATLAN_OAUTH_CLIENT_ID"] = "env-client-id" + + client = AtlanClient() + + assert client._oauth_token_manager is None + + +class TestEdgeCases: + """Test edge cases and unusual scenarios""" + + @patch("httpx.Client.post") + def test_default_expires_in(self, mock_post, clear_env_vars): + """Missing expiresIn should use default""" + mock_response = Mock() + mock_response.json.return_value = { + "accessToken": "test-token", + "tokenType": "Bearer", + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + manager = OAuthTokenManager( + base_url="https://test.atlan.com", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + token = manager.get_token() + assert token == "test-token" + + manager.close() + + def test_url_with_trailing_slash(self, clear_env_vars): + """Base URL with trailing slash""" + manager = OAuthTokenManager( + base_url="https://test.atlan.com/", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert "/api/service/oauth-clients/token" in manager.token_url + + manager.close() + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/uv.lock b/uv.lock index 484eb8ae2..9f827b5ff 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "authlib" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -111,6 +123,8 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, @@ -119,6 +133,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, @@ -127,6 +145,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, @@ -134,6 +156,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, @@ -141,6 +167,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" }, { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, @@ -149,6 +179,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" }, ] [[package]] @@ -481,6 +513,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, @@ -490,6 +523,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, @@ -499,14 +535,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/56/d2/4482d97c948c029be08cb29854a91bd2ae8da7eb9c4152461f1244dcea70/cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012", size = 3576812, upload-time = "2025-08-05T23:59:04.833Z" }, { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694, upload-time = "2025-08-05T23:59:06.66Z" }, { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010, upload-time = "2025-08-05T23:59:08.14Z" }, { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377, upload-time = "2025-08-05T23:59:09.584Z" }, { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609, upload-time = "2025-08-05T23:59:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/78/6d/c49ccf243f0a1b0781c2a8de8123ee552f0c8a417c6367a24d2ecb7c11b3/cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18", size = 3322156, upload-time = "2025-08-05T23:59:13.597Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, ] [[package]] @@ -1330,6 +1372,7 @@ wheels = [ name = "pyatlan" source = { editable = "." } dependencies = [ + { name = "authlib" }, { name = "httpx" }, { name = "httpx-retries" }, { name = "jinja2" }, @@ -1359,6 +1402,7 @@ dev = [ { name = "retry" }, { name = "ruff" }, { name = "twine" }, + { name = "types-authlib" }, { name = "types-retry" }, { name = "types-setuptools" }, { name = "vcrpy" }, @@ -1375,6 +1419,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "authlib", specifier = "~=1.6.5" }, { name = "httpx", specifier = "~=0.28.1" }, { name = "httpx-retries", specifier = "~=0.4.5" }, { name = "jinja2", specifier = "~=3.1.6" }, @@ -1404,6 +1449,7 @@ dev = [ { name = "retry", specifier = "~=0.9.2" }, { name = "ruff", specifier = "~=0.14.0" }, { name = "twine", specifier = "~=6.2.0" }, + { name = "types-authlib" }, { name = "types-retry", specifier = "~=0.9.9.20250322" }, { name = "types-setuptools", specifier = "~=80.9.0.20250822" }, { name = "vcrpy", specifier = "~=7.0.0" }, @@ -2111,6 +2157,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] +[[package]] +name = "types-authlib" +version = "1.6.5.20251005" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/6b/49489c81597f9e58cf61e27618d2bcd0859c2f492339e2ec20466d412acf/types_authlib-1.6.5.20251005.tar.gz", hash = "sha256:b45303969716d95115503de1677a76f28813ed25dd62e22a7cc21b8bf43842b5", size = 37108, upload-time = "2025-10-05T03:00:38.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/57/7dc5f7a8451647c87b8095445947f51483cc3bb02e53414b989f28eeb5a7/types_authlib-1.6.5.20251005-py3-none-any.whl", hash = "sha256:dc635602cae4adf8aef814e34943a8f5542db5d4697468b9f6e51d01f77e8028", size = 79270, upload-time = "2025-10-05T03:00:37.865Z" }, +] + [[package]] name = "types-retry" version = "0.9.9.20250322"