From 366d5b3e18ea9f54a271d24650f6aa878ac3764e Mon Sep 17 00:00:00 2001 From: Jakub Szymanski Date: Mon, 2 Feb 2026 10:16:49 +0100 Subject: [PATCH 1/5] - Added `ApiTokenAuth` for authenticating with a fixed API token. - Introduced `DynamicApiTokenAuth` / `AsyncDynamicApiTokenAuth` for provider-based authentication with per-request token resolution. Tokens are persisted in storage and automatically rotated when entering the rotation window. - Implemented token endpoints clients (sync + async): `heartbeat()` and `rotate()`. - Added token managers (sync + async) providing: - rotation/expiration handling, - `configure()` and `configure_from_env()` helpers. - Added token storage backends: - `InMemoryApiTokenStorage` and `JsonFileApiTokenStorage`, - async variants for both. --- CHANGELOG.md | 26 +++ pyproject.toml | 2 +- rtbhouse_sdk/__init__.py | 2 +- rtbhouse_sdk/_utils.py | 5 + rtbhouse_sdk/api_tokens/__init__.py | 0 rtbhouse_sdk/api_tokens/api.py | 110 +++++++++++ rtbhouse_sdk/api_tokens/managers.py | 278 ++++++++++++++++++++++++++++ rtbhouse_sdk/api_tokens/models.py | 12 ++ rtbhouse_sdk/api_tokens/storages.py | 163 ++++++++++++++++ rtbhouse_sdk/client.py | 121 +++++++++++- 10 files changed, 708 insertions(+), 11 deletions(-) create mode 100644 rtbhouse_sdk/api_tokens/__init__.py create mode 100644 rtbhouse_sdk/api_tokens/api.py create mode 100644 rtbhouse_sdk/api_tokens/managers.py create mode 100644 rtbhouse_sdk/api_tokens/models.py create mode 100644 rtbhouse_sdk/api_tokens/storages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e99e173..252a085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +# v16.0.0 +- Added `ApiTokenAuth` for authenticating with a API token. +- Added `DynamicApiTokenAuth` / `AsyncDynamicApiTokenAuth` for authenticating with API tokens with automatic rotation (sync + async). +- Added API token storage implementations: in-memory and JSON file (sync + async). + +Examples: +```python +from rtbhouse_sdk.client import ApiTokenAuth, Client + +auth = ApiTokenAuth(token="your_api_token") +api = Client(auth=auth) + +info = api.get_user_info() +api.close() +``` +or in async +```python +from rtbhouse_sdk.client import ApiTokenAuth, AsyncClient + +auth = ApiTokenAuth(token="your_api_token") +api = AsyncClient(auth=auth) + +info = await api.get_user_info() +api.close() +``` + # v15.0.0 - [breaking change] dropped support for python 3.9 (which is end-of-life), please use python 3.10+ diff --git a/pyproject.toml b/pyproject.toml index 9d021fe..5f5d3f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rtbhouse-sdk" -version = "15.0.0" +version = "16.0.0" description = "RTB House SDK" authors = ["RTB House Apps Team "] license = "BSD License" diff --git a/rtbhouse_sdk/__init__.py b/rtbhouse_sdk/__init__.py index 84adc89..929e16d 100644 --- a/rtbhouse_sdk/__init__.py +++ b/rtbhouse_sdk/__init__.py @@ -1,3 +1,3 @@ """RTB House Python SDK.""" -__version__ = "15.0.0" +__version__ = "16.0.0" diff --git a/rtbhouse_sdk/_utils.py b/rtbhouse_sdk/_utils.py index 1d216ad..a7172f9 100644 --- a/rtbhouse_sdk/_utils.py +++ b/rtbhouse_sdk/_utils.py @@ -1,6 +1,7 @@ """Utils used in SDK.""" import re +from datetime import datetime, timezone from pydantic.version import VERSION as PYDANTIC_VERSION @@ -28,3 +29,7 @@ def underscore(word: str) -> str: word = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", word) word = word.replace("-", "_") return word.lower() + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) diff --git a/rtbhouse_sdk/api_tokens/__init__.py b/rtbhouse_sdk/api_tokens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rtbhouse_sdk/api_tokens/api.py b/rtbhouse_sdk/api_tokens/api.py new file mode 100644 index 0000000..6cd92ed --- /dev/null +++ b/rtbhouse_sdk/api_tokens/api.py @@ -0,0 +1,110 @@ +"""Contains classes for interacting with the API tokens endpoints.""" + +from datetime import datetime + +import httpx + +from rtbhouse_sdk.client import DEFAULT_TIMEOUT, _build_headers, _validate_response, build_base_url +from rtbhouse_sdk.exceptions import ApiException +from rtbhouse_sdk.schema import CamelizedBaseModel + + +class RotatedApiToken(CamelizedBaseModel): + token: str + expires_at: datetime + + +class ApiTokenStatus(CamelizedBaseModel): + expires_at: datetime + is_expired: bool + can_rotate: bool + + +class ApiTokensAPI: + def __init__(self) -> None: + self._base_url = build_base_url() + self._headers = _build_headers() + self._timeout = DEFAULT_TIMEOUT.total_seconds() + + @property + def _httpx_client(self) -> httpx.Client: + return httpx.Client( + base_url=self._base_url, + headers=self._headers, + timeout=self._timeout, + ) + + def heartbeat(self, token: str) -> ApiTokenStatus: + with self._httpx_client as client: + response = client.get( + "/tokens/current/heartbeat", + headers={"Authorization": f"Bearer {token}"}, + ) + _validate_response(response) + try: + resp_json = response.json() + data = resp_json["data"] + except (ValueError, KeyError) as exc: + raise ApiException("Invalid response format") from exc + + return ApiTokenStatus(**data) + + def rotate(self, token: str) -> RotatedApiToken: + with self._httpx_client as client: + response = client.post( + "/tokens/current/rotate", + headers={"Authorization": f"Bearer {token}"}, + ) + _validate_response(response) + try: + resp_json = response.json() + data = resp_json["data"] + except (ValueError, KeyError) as exc: + raise ApiException("Invalid response format") from exc + + return RotatedApiToken(**data) + + +class AsyncApiTokensAPI: + def __init__(self) -> None: + self._base_url = build_base_url() + self._headers = _build_headers() + self._timeout = DEFAULT_TIMEOUT.total_seconds() + + @property + def _httpx_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self._base_url, + headers=self._headers, + timeout=self._timeout, + ) + + async def heartbeat(self, token: str) -> ApiTokenStatus: + async with self._httpx_client as client: + response = await client.get( + "/tokens/current/heartbeat", + headers={"Authorization": f"Bearer {token}"}, + ) + _validate_response(response) + try: + resp_json = response.json() + data = resp_json["data"] + except (ValueError, KeyError) as exc: + raise ApiException("Invalid response format") from exc + + return ApiTokenStatus(**data) + + async def rotate(self, token: str) -> RotatedApiToken: + async with self._httpx_client as client: + response = await client.post( + "/tokens/current/rotate", + headers={"Authorization": f"Bearer {token}"}, + ) + _validate_response(response) + try: + resp_json = response.json() + data = resp_json["data"] + except (ValueError, KeyError) as exc: + raise ApiException("Invalid response format") from exc + + return RotatedApiToken(**data) diff --git a/rtbhouse_sdk/api_tokens/managers.py b/rtbhouse_sdk/api_tokens/managers.py new file mode 100644 index 0000000..a6fe456 --- /dev/null +++ b/rtbhouse_sdk/api_tokens/managers.py @@ -0,0 +1,278 @@ +"""Contains classes for managing API tokens""" + +import asyncio +import os +import threading +import warnings +from datetime import datetime, timedelta + +from rtbhouse_sdk._utils import utcnow +from rtbhouse_sdk.api_tokens.api import ApiTokensAPI, AsyncApiTokensAPI +from rtbhouse_sdk.api_tokens.models import ApiToken +from rtbhouse_sdk.api_tokens.storages import ApiTokenStorage, ApiTokenStorageException, AsyncApiTokenStorage +from rtbhouse_sdk.client import ApiTokenProvider, AsyncApiTokenProvider + +ROTATION_WINDOW = timedelta(days=4) + +EXPIRED_MSG = "API token expired. Please manually create a new one and configure it by calling the configure() method." + + +class TokenExpiredException(Exception): + """Exception raised when the API token has expired.""" + + +class ApiTokenManager(ApiTokenProvider): + """ + Manages the lifecycle of an API token, including configuration, rotation and expiration handling. + + Initial token configuration example: + ``` + manager = ApiTokenManager(storage) + manager.configure(token="your_initial_token") + ``` + + You can also configure it from environment variable: + ``` + manager.configure_from_env(env_var="RTBH_API_TOKEN") + ``` + """ + + def __init__(self, storage: ApiTokenStorage) -> None: + self._storage = storage + self._api = ApiTokensAPI() + self._lock = threading.Lock() + self._expiration_margin = timedelta(minutes=1) + + def configure(self, token: str) -> None: + status = self._api.heartbeat(token) + + if status.is_expired: + raise TokenExpiredException(EXPIRED_MSG) + + api_token = ApiToken( + token=token, + expires_at=status.expires_at, + ) + self._storage.save(api_token) + + def configure_from_env( + self, + env_var: str = "RTBH_API_TOKEN", + overwrite: bool = False, + ) -> bool: + token = os.getenv(env_var) + if token is None: + return False + + if not overwrite and self.is_configured(): + return False + + self.configure(token) + return True + + def is_configured(self) -> bool: + try: + self._storage.load() + return True + except ApiTokenStorageException: + return False + + def get_token(self) -> str: + api_token = self._storage.load() + now = utcnow() + + self._raise_if_expired(api_token, now) + + if not self._in_rotation_window(api_token, now): + # fast path + return api_token.token + + with self._lock: + # reload inside lock + api_token = self._storage.load() + now = utcnow() + + self._raise_if_expired(api_token, now) + + if not self._in_rotation_window(api_token, now): + return api_token.token + + status = self._api.heartbeat(api_token.token) + + if status.is_expired: + raise TokenExpiredException(EXPIRED_MSG) + + if not status.can_rotate: + if self._in_rotation_window(api_token, now): + warnings.warn( + "Couldn't rotate API token and it may expire soon. " + "Please check whether it has already been rotated." + ) + return api_token.token + + rotated = self._api.rotate(api_token.token) + + new_api_token = ApiToken( + token=rotated.token, + expires_at=rotated.expires_at, + ) + self._storage.save(new_api_token) + + return new_api_token.token + + def keep_alive(self) -> None: + with self._lock: + api_token = self._storage.load() + now = utcnow() + + self._raise_if_expired(api_token, now) + + status = self._api.heartbeat(api_token.token) + + if status.is_expired: + raise TokenExpiredException(EXPIRED_MSG) + + if not status.can_rotate: + if self._in_rotation_window(api_token, now): + warnings.warn( + "Couldn't rotate API token and it may expire soon. " + "Please check whether it has already been rotated." + ) + return + + rotated = self._api.rotate(api_token.token) + new_api_token = ApiToken( + token=rotated.token, + expires_at=rotated.expires_at, + ) + self._storage.save(new_api_token) + + def _raise_if_expired(self, api_token: ApiToken, now: datetime) -> None: + if now >= api_token.expires_at - self._expiration_margin: + raise TokenExpiredException(EXPIRED_MSG) + + @staticmethod + def _in_rotation_window(api_token: ApiToken, now: datetime) -> bool: + return now >= (api_token.expires_at - ROTATION_WINDOW) + + +class AsyncApiTokenManager(AsyncApiTokenProvider): + """Asynchronous version of ApiTokenManager.""" + + def __init__(self, storage: AsyncApiTokenStorage) -> None: + self._storage = storage + self._api = AsyncApiTokensAPI() + self._lock = asyncio.Lock() + self._expiration_margin = timedelta(minutes=1) + + async def configure(self, token: str) -> None: + status = await self._api.heartbeat(token) + + if status.is_expired: + raise TokenExpiredException(EXPIRED_MSG) + + api_token = ApiToken( + token=token, + expires_at=status.expires_at, + ) + await self._storage.save(api_token) + + async def configure_from_env( + self, + env_var: str = "RTBH_API_TOKEN", + overwrite: bool = False, + ) -> bool: + token = os.getenv(env_var) + if token is None: + return False + + if not overwrite and await self.is_configured(): + return False + + await self.configure(token) + return True + + async def is_configured(self) -> bool: + try: + await self._storage.load() + return True + except ApiTokenStorageException: + return False + + async def get_token(self) -> str: + api_token = await self._storage.load() + now = utcnow() + + self._raise_if_expired(api_token, now) + + if not self._in_rotation_window(api_token, now): + # fast path + return api_token.token + + async with self._lock: + # reload inside lock + api_token = await self._storage.load() + now = utcnow() + + self._raise_if_expired(api_token, now) + + if not self._in_rotation_window(api_token, now): + return api_token.token + + status = await self._api.heartbeat(api_token.token) + + if status.is_expired: + raise TokenExpiredException(EXPIRED_MSG) + + if not status.can_rotate: + if self._in_rotation_window(api_token, now): + warnings.warn( + "Couldn't rotate API token and it may expire soon. " + "Please check whether it has already been rotated." + ) + return api_token.token + + rotated = await self._api.rotate(api_token.token) + + new_api_token = ApiToken( + token=rotated.token, + expires_at=rotated.expires_at, + ) + await self._storage.save(new_api_token) + + return new_api_token.token + + async def keep_alive(self) -> None: + async with self._lock: + api_token = await self._storage.load() + now = utcnow() + + self._raise_if_expired(api_token, now) + + status = await self._api.heartbeat(api_token.token) + + if status.is_expired: + raise TokenExpiredException(EXPIRED_MSG) + + if not status.can_rotate: + if self._in_rotation_window(api_token, now): + warnings.warn( + "Couldn't rotate API token and it may expire soon. " + "Please check whether it has already been rotated." + ) + return + + rotated = await self._api.rotate(api_token.token) + new_api_token = ApiToken( + token=rotated.token, + expires_at=rotated.expires_at, + ) + await self._storage.save(new_api_token) + + def _raise_if_expired(self, api_token: ApiToken, now: datetime) -> None: + if now >= api_token.expires_at - self._expiration_margin: + raise TokenExpiredException(EXPIRED_MSG) + + @staticmethod + def _in_rotation_window(api_token: ApiToken, now: datetime) -> bool: + return now >= (api_token.expires_at - ROTATION_WINDOW) diff --git a/rtbhouse_sdk/api_tokens/models.py b/rtbhouse_sdk/api_tokens/models.py new file mode 100644 index 0000000..54fe0e9 --- /dev/null +++ b/rtbhouse_sdk/api_tokens/models.py @@ -0,0 +1,12 @@ +"""Contains models related to API tokens.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class ApiToken(BaseModel): + """Basic model representing an API token.""" + + token: str + expires_at: datetime diff --git a/rtbhouse_sdk/api_tokens/storages.py b/rtbhouse_sdk/api_tokens/storages.py new file mode 100644 index 0000000..c27e7d7 --- /dev/null +++ b/rtbhouse_sdk/api_tokens/storages.py @@ -0,0 +1,163 @@ +"""Contains classes for API token storage implementations.""" + +import asyncio +import os +import tempfile +from abc import ABC, abstractmethod +from pathlib import Path + +from rtbhouse_sdk.api_tokens.models import ApiToken + +DEFAULT_JSON_FILE_PATH = "~/.rtbhouse/api_token.json" + + +class ApiTokenStorageException(Exception): + """Exception raised for errors in the API token storage operations.""" + + +class ApiTokenStorage(ABC): + """Abstract base class for API token storage implementations.""" + + @abstractmethod + def load(self) -> ApiToken: + pass + + @abstractmethod + def save(self, api_token: ApiToken) -> None: + pass + + @abstractmethod + def delete(self) -> None: + pass + + +class InMemoryApiTokenStorage(ApiTokenStorage): + """In-memory storage for API tokens.""" + + def __init__(self, api_token: ApiToken | None = None) -> None: + super().__init__() + self._api_token = api_token + + def load(self) -> ApiToken: + if self._api_token is None: + raise ApiTokenStorageException("No API token stored in memory") + return self._api_token + + def save(self, api_token: ApiToken) -> None: + self._api_token = api_token + + def delete(self) -> None: + self._api_token = None + + +class JsonFileApiTokenStorage(ApiTokenStorage): + """JSON file storage for API tokens.""" + + def __init__(self, path: str | Path = DEFAULT_JSON_FILE_PATH) -> None: + super().__init__() + self._path = Path(path).expanduser() + + def load(self) -> ApiToken: + try: + text = self._path.read_text(encoding="utf-8") + except FileNotFoundError as e: + raise ApiTokenStorageException("JSON file does not exist. Please configure token first.") from e + except OSError as e: + raise ApiTokenStorageException("Failed to read API token JSON file") from e + + try: + return ApiToken.model_validate_json(text) + except ValueError as e: + raise ApiTokenStorageException("Invalid API token JSON file format") from e + + def save(self, api_token: ApiToken) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + text = api_token.model_dump_json() + self._atomic_write_text(text) + + def delete(self) -> None: + try: + self._path.unlink() + except FileNotFoundError: + pass + + def _atomic_write_text(self, text: str) -> None: + descriptor, temp_path = tempfile.mkstemp( + prefix=self._path.name + ".", + dir=self._path.parent, + ) + try: + with os.fdopen( + descriptor, + "w", + encoding="utf-8", + ) as file: + file.write(text) + file.flush() + os.fsync(file.fileno()) + + try: + os.chmod(temp_path, 0o600) + except OSError: + pass + + os.replace(temp_path, self._path) + + finally: + try: + os.remove(temp_path) + except FileNotFoundError: + pass + except OSError: + pass + + +class AsyncApiTokenStorage(ABC): + """Abstract base class for asynchronous API token storage implementations.""" + + @abstractmethod + async def load(self) -> ApiToken: + pass + + @abstractmethod + async def save(self, api_token: ApiToken) -> None: + pass + + @abstractmethod + async def delete(self) -> None: + pass + + +class AsyncInMemoryApiTokenStorage(AsyncApiTokenStorage): + """Asynchronous in-memory storage for API tokens.""" + + def __init__(self, api_token: ApiToken | None = None) -> None: + super().__init__() + self._api_token = api_token + + async def load(self) -> ApiToken: + if self._api_token is None: + raise ApiTokenStorageException("No API token stored in memory") + return self._api_token + + async def save(self, api_token: ApiToken) -> None: + self._api_token = api_token + + async def delete(self) -> None: + self._api_token = None + + +class AsyncJsonFileApiTokenStorage(AsyncApiTokenStorage): + """Asynchronous JSON file storage for API tokens.""" + + def __init__(self, path: str | Path = DEFAULT_JSON_FILE_PATH) -> None: + self._sync = JsonFileApiTokenStorage(path) + + async def load(self) -> ApiToken: + return self._sync.load() + + async def save(self, api_token: ApiToken) -> None: + return await asyncio.to_thread(self._sync.save, api_token) + + async def delete(self) -> None: + return await asyncio.to_thread(self._sync.delete) diff --git a/rtbhouse_sdk/client.py b/rtbhouse_sdk/client.py index f67b8d4..4b58dac 100644 --- a/rtbhouse_sdk/client.py +++ b/rtbhouse_sdk/client.py @@ -3,7 +3,8 @@ # pylint: disable=too-many-arguments import dataclasses import warnings -from collections.abc import AsyncIterable, Generator, Iterable +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Callable, Generator, Iterable from datetime import date, timedelta from json import JSONDecodeError from types import TracebackType @@ -28,6 +29,16 @@ MAX_CURSOR_ROWS = 10000 +@dataclasses.dataclass +class ApiTokenAuth: + token: str + + +@dataclasses.dataclass +class DynamicApiTokenAuth: + manager: "ApiTokenProvider" + + @dataclasses.dataclass class BasicAuth: username: str @@ -61,7 +72,7 @@ class Client: def __init__( self, - auth: BasicAuth | BasicTokenAuth, + auth: ApiTokenAuth | DynamicApiTokenAuth | BasicAuth | BasicTokenAuth, timeout: timedelta = DEFAULT_TIMEOUT, ): self._httpx_client = httpx.Client( @@ -95,12 +106,33 @@ def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: except (ValueError, KeyError) as exc: raise ApiException("Invalid response format") from exc + def _post(self, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None) -> Any: + response = self._httpx_client.post( + path, + json=data, + params=params, + ) + _validate_response(response) + try: + resp_json = response.json() + return resp_json["data"] + except (ValueError, KeyError) as exc: + raise ApiException("Invalid response format") from exc + def _get_dict(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: data = self._get(path, params) if not isinstance(data, dict): raise ValueError("Result is not a dict") return data + def _post_dict( + self, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + data = self._post(path, data, params) + if not isinstance(data, dict): + raise ValueError("Result is not a dict") + return data + def _get_list_of_dicts(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: data = self._get(path, params) if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): @@ -233,6 +265,11 @@ def get_summary_stats( return [schema.Stats(**st) for st in data] +@dataclasses.dataclass +class AsyncDynamicApiTokenAuth: + manager: "AsyncApiTokenProvider" + + class AsyncClient: """ An asynchronous API client. @@ -247,7 +284,7 @@ class AsyncClient: def __init__( self, - auth: BasicAuth | BasicTokenAuth, + auth: ApiTokenAuth | AsyncDynamicApiTokenAuth | BasicAuth | BasicTokenAuth, timeout: timedelta = DEFAULT_TIMEOUT, ) -> None: self._httpx_client = httpx.AsyncClient( @@ -281,6 +318,15 @@ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: except (ValueError, KeyError) as exc: raise ApiException("Invalid response format") from exc + async def _post(self, path: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> Any: + response = await self._httpx_client.post(path, json=data, params=params) + _validate_response(response) + try: + resp_json = response.json() + return resp_json["data"] + except (ValueError, KeyError) as exc: + raise ApiException("Invalid response format") from exc + async def _get_dict(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: data = await self._get(path, params) if not isinstance(data, dict): @@ -422,6 +468,41 @@ async def get_summary_stats( return [schema.Stats(**st) for st in data] +class _HttpxApiTokenAuth(httpx.Auth): + """API token auth backend.""" + + def __init__(self, token: str) -> None: + self._token = token + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self._token}" + yield request + + +class _HttpxProviderApiTokenAuth(httpx.Auth): + """API token auth backend.""" + + def __init__(self, token_provider: Callable[[], str]) -> None: + self._token_provider = token_provider + + def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + token = self._token_provider() + request.headers["Authorization"] = f"Bearer {token}" + yield request + + +class _AsyncHttpxProviderApiTokenAuth(httpx.Auth): + """API token auth backend.""" + + def __init__(self, token_provider: Callable[[], Awaitable[str]]) -> None: + self._token_provider = token_provider + + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: + token = await self._token_provider() + request.headers["Authorization"] = f"Bearer {token}" + yield request + + class _HttpxBasicTokenAuth(httpx.Auth): """Basic token auth backend.""" @@ -433,6 +514,18 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re yield request +class ApiTokenProvider(ABC): # pylint: disable=too-few-public-methods + @abstractmethod + def get_token(self) -> str: + pass + + +class AsyncApiTokenProvider(ABC): # pylint: disable=too-few-public-methods + @abstractmethod + async def get_token(self) -> str: + pass + + def build_base_url() -> str: return f"{API_BASE_URL}/{API_VERSION}" @@ -443,12 +536,22 @@ def _build_headers() -> dict[str, str]: } -def _choose_auth_backend(auth: BasicAuth | BasicTokenAuth) -> httpx.Auth: - if isinstance(auth, BasicAuth): - return httpx.BasicAuth(auth.username, auth.password) - if isinstance(auth, BasicTokenAuth): - return _HttpxBasicTokenAuth(auth.token) - raise ValueError("Unknown auth method") +def _choose_auth_backend( + auth: ApiTokenAuth | DynamicApiTokenAuth | AsyncDynamicApiTokenAuth | BasicAuth | BasicTokenAuth, +) -> httpx.Auth: + match auth: + case ApiTokenAuth(token=token): + return _HttpxApiTokenAuth(token) + case DynamicApiTokenAuth(manager=manager): + return _HttpxProviderApiTokenAuth(manager.get_token) + case AsyncDynamicApiTokenAuth(manager=manager): + return _AsyncHttpxProviderApiTokenAuth(manager.get_token) + case BasicAuth(username=username, password=password): + return httpx.BasicAuth(username, password) + case BasicTokenAuth(token=token): + return _HttpxBasicTokenAuth(token) + case _: + raise ValueError("Unknown auth method") def _validate_response(response: httpx.Response) -> None: From cb4f3ff83c7e9364389ac8cb8b3a666aab5e2775 Mon Sep 17 00:00:00 2001 From: Jakub Szymanski Date: Mon, 2 Feb 2026 17:29:05 +0100 Subject: [PATCH 2/5] don't use duplicated manager methods, configure_from_env to not return bool --- rtbhouse_sdk/api_tokens/managers.py | 69 +++++++++++++---------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/rtbhouse_sdk/api_tokens/managers.py b/rtbhouse_sdk/api_tokens/managers.py index a6fe456..ac615d8 100644 --- a/rtbhouse_sdk/api_tokens/managers.py +++ b/rtbhouse_sdk/api_tokens/managers.py @@ -14,6 +14,8 @@ ROTATION_WINDOW = timedelta(days=4) +DEFAULT_TOKEN_CONFIGURE_ENV_VAR = "RTBH_API_TOKEN" + EXPIRED_MSG = "API token expired. Please manually create a new one and configure it by calling the configure() method." @@ -57,18 +59,17 @@ def configure(self, token: str) -> None: def configure_from_env( self, - env_var: str = "RTBH_API_TOKEN", + env_var: str = DEFAULT_TOKEN_CONFIGURE_ENV_VAR, overwrite: bool = False, - ) -> bool: + ) -> None: token = os.getenv(env_var) if token is None: - return False + raise ValueError(f"Environment variable '{env_var}' is not set") if not overwrite and self.is_configured(): - return False + return self.configure(token) - return True def is_configured(self) -> bool: try: @@ -81,9 +82,9 @@ def get_token(self) -> str: api_token = self._storage.load() now = utcnow() - self._raise_if_expired(api_token, now) + _raise_if_expired(api_token, now, self._expiration_margin) - if not self._in_rotation_window(api_token, now): + if not _in_rotation_window(api_token, now): # fast path return api_token.token @@ -92,9 +93,9 @@ def get_token(self) -> str: api_token = self._storage.load() now = utcnow() - self._raise_if_expired(api_token, now) + _raise_if_expired(api_token, now, self._expiration_margin) - if not self._in_rotation_window(api_token, now): + if not _in_rotation_window(api_token, now): return api_token.token status = self._api.heartbeat(api_token.token) @@ -103,7 +104,7 @@ def get_token(self) -> str: raise TokenExpiredException(EXPIRED_MSG) if not status.can_rotate: - if self._in_rotation_window(api_token, now): + if _in_rotation_window(api_token, now): warnings.warn( "Couldn't rotate API token and it may expire soon. " "Please check whether it has already been rotated." @@ -125,7 +126,7 @@ def keep_alive(self) -> None: api_token = self._storage.load() now = utcnow() - self._raise_if_expired(api_token, now) + _raise_if_expired(api_token, now, self._expiration_margin) status = self._api.heartbeat(api_token.token) @@ -133,7 +134,7 @@ def keep_alive(self) -> None: raise TokenExpiredException(EXPIRED_MSG) if not status.can_rotate: - if self._in_rotation_window(api_token, now): + if _in_rotation_window(api_token, now): warnings.warn( "Couldn't rotate API token and it may expire soon. " "Please check whether it has already been rotated." @@ -147,14 +148,6 @@ def keep_alive(self) -> None: ) self._storage.save(new_api_token) - def _raise_if_expired(self, api_token: ApiToken, now: datetime) -> None: - if now >= api_token.expires_at - self._expiration_margin: - raise TokenExpiredException(EXPIRED_MSG) - - @staticmethod - def _in_rotation_window(api_token: ApiToken, now: datetime) -> bool: - return now >= (api_token.expires_at - ROTATION_WINDOW) - class AsyncApiTokenManager(AsyncApiTokenProvider): """Asynchronous version of ApiTokenManager.""" @@ -179,18 +172,17 @@ async def configure(self, token: str) -> None: async def configure_from_env( self, - env_var: str = "RTBH_API_TOKEN", + env_var: str = DEFAULT_TOKEN_CONFIGURE_ENV_VAR, overwrite: bool = False, - ) -> bool: + ) -> None: token = os.getenv(env_var) if token is None: - return False + raise ValueError(f"Environment variable '{env_var}' is not set") if not overwrite and await self.is_configured(): - return False + return await self.configure(token) - return True async def is_configured(self) -> bool: try: @@ -203,9 +195,9 @@ async def get_token(self) -> str: api_token = await self._storage.load() now = utcnow() - self._raise_if_expired(api_token, now) + _raise_if_expired(api_token, now, self._expiration_margin) - if not self._in_rotation_window(api_token, now): + if not _in_rotation_window(api_token, now): # fast path return api_token.token @@ -214,9 +206,9 @@ async def get_token(self) -> str: api_token = await self._storage.load() now = utcnow() - self._raise_if_expired(api_token, now) + _raise_if_expired(api_token, now, self._expiration_margin) - if not self._in_rotation_window(api_token, now): + if not _in_rotation_window(api_token, now): return api_token.token status = await self._api.heartbeat(api_token.token) @@ -225,7 +217,7 @@ async def get_token(self) -> str: raise TokenExpiredException(EXPIRED_MSG) if not status.can_rotate: - if self._in_rotation_window(api_token, now): + if _in_rotation_window(api_token, now): warnings.warn( "Couldn't rotate API token and it may expire soon. " "Please check whether it has already been rotated." @@ -247,7 +239,7 @@ async def keep_alive(self) -> None: api_token = await self._storage.load() now = utcnow() - self._raise_if_expired(api_token, now) + _raise_if_expired(api_token, now, self._expiration_margin) status = await self._api.heartbeat(api_token.token) @@ -255,7 +247,7 @@ async def keep_alive(self) -> None: raise TokenExpiredException(EXPIRED_MSG) if not status.can_rotate: - if self._in_rotation_window(api_token, now): + if _in_rotation_window(api_token, now): warnings.warn( "Couldn't rotate API token and it may expire soon. " "Please check whether it has already been rotated." @@ -269,10 +261,11 @@ async def keep_alive(self) -> None: ) await self._storage.save(new_api_token) - def _raise_if_expired(self, api_token: ApiToken, now: datetime) -> None: - if now >= api_token.expires_at - self._expiration_margin: - raise TokenExpiredException(EXPIRED_MSG) - @staticmethod - def _in_rotation_window(api_token: ApiToken, now: datetime) -> bool: - return now >= (api_token.expires_at - ROTATION_WINDOW) +def _raise_if_expired(api_token: ApiToken, now: datetime, expiration_margin: timedelta) -> None: + if now >= api_token.expires_at - expiration_margin: + raise TokenExpiredException(EXPIRED_MSG) + + +def _in_rotation_window(api_token: ApiToken, now: datetime) -> bool: + return now >= (api_token.expires_at - ROTATION_WINDOW) From 79aa9bfd9f7bb916bf7ff4f7962852ff06c36f31 Mon Sep 17 00:00:00 2001 From: Jakub Szymanski Date: Mon, 9 Mar 2026 10:03:39 +0100 Subject: [PATCH 3/5] improvments after review --- poetry.lock | 24 +- pyproject.toml | 2 + rtbhouse_sdk/__main__.py | 6 + rtbhouse_sdk/api_tokens/api.py | 110 --------- rtbhouse_sdk/api_tokens/cli.py | 192 +++++++++++++++ rtbhouse_sdk/api_tokens/managers.py | 227 ++++++------------ rtbhouse_sdk/api_tokens/storages/__init__.py | 0 rtbhouse_sdk/api_tokens/storages/base.py | 33 +++ rtbhouse_sdk/api_tokens/storages/in_memory.py | 41 ++++ .../{storages.py => storages/json_file.py} | 105 ++------ rtbhouse_sdk/client.py | 79 ++++-- rtbhouse_sdk/schema.py | 10 + 12 files changed, 457 insertions(+), 372 deletions(-) create mode 100644 rtbhouse_sdk/__main__.py delete mode 100644 rtbhouse_sdk/api_tokens/api.py create mode 100644 rtbhouse_sdk/api_tokens/cli.py create mode 100644 rtbhouse_sdk/api_tokens/storages/__init__.py create mode 100644 rtbhouse_sdk/api_tokens/storages/base.py create mode 100644 rtbhouse_sdk/api_tokens/storages/in_memory.py rename rtbhouse_sdk/api_tokens/{storages.py => storages/json_file.py} (50%) diff --git a/poetry.lock b/poetry.lock index 66e91fe..d812817 100644 --- a/poetry.lock +++ b/poetry.lock @@ -107,6 +107,17 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cachetools" +version = "7.0.3" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.10" +files = [ + {file = "cachetools-7.0.3-py3-none-any.whl", hash = "sha256:c128ffca156eef344c25fcd08a96a5952803786fa33097f5f2d49edf76f79d53"}, + {file = "cachetools-7.0.3.tar.gz", hash = "sha256:8c246313b95849964e54a909c03b327a87ab0428b068fac10da7b105ca275ef6"}, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -992,6 +1003,17 @@ files = [ {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, ] +[[package]] +name = "types-cachetools" +version = "6.2.0.20251022" +description = "Typing stubs for cachetools" +optional = false +python-versions = ">=3.9" +files = [ + {file = "types_cachetools-6.2.0.20251022-py3-none-any.whl", hash = "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad"}, + {file = "types_cachetools-6.2.0.20251022.tar.gz", hash = "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1020,4 +1042,4 @@ typing-extensions = ">=4.12.0" [metadata] lock-version = "2.0" python-versions = ">=3.10, <4.0" -content-hash = "e51c4079d07bf1b47173e7747697435e4548edd1755e0edd3446774bcd26d188" +content-hash = "39616a78e3908e5224653b788f21a2bf75dae01895141a2b2f64a94b5644c559" diff --git a/pyproject.toml b/pyproject.toml index 5f5d3f9..8cd72f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ python = ">=3.10, <4.0" httpx = "^0.28.0" pydantic = ">=1.9, <3.0" +cachetools = "^7.0.3" +types-cachetools = "^6.2.0.20251022" [tool.poetry.group.dev.dependencies] pydantic = "^2.0.0" # required for tests diff --git a/rtbhouse_sdk/__main__.py b/rtbhouse_sdk/__main__.py new file mode 100644 index 0000000..bcc8dde --- /dev/null +++ b/rtbhouse_sdk/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for the RTB House Python SDK CLI.""" + +from .api_tokens.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rtbhouse_sdk/api_tokens/api.py b/rtbhouse_sdk/api_tokens/api.py deleted file mode 100644 index 6cd92ed..0000000 --- a/rtbhouse_sdk/api_tokens/api.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Contains classes for interacting with the API tokens endpoints.""" - -from datetime import datetime - -import httpx - -from rtbhouse_sdk.client import DEFAULT_TIMEOUT, _build_headers, _validate_response, build_base_url -from rtbhouse_sdk.exceptions import ApiException -from rtbhouse_sdk.schema import CamelizedBaseModel - - -class RotatedApiToken(CamelizedBaseModel): - token: str - expires_at: datetime - - -class ApiTokenStatus(CamelizedBaseModel): - expires_at: datetime - is_expired: bool - can_rotate: bool - - -class ApiTokensAPI: - def __init__(self) -> None: - self._base_url = build_base_url() - self._headers = _build_headers() - self._timeout = DEFAULT_TIMEOUT.total_seconds() - - @property - def _httpx_client(self) -> httpx.Client: - return httpx.Client( - base_url=self._base_url, - headers=self._headers, - timeout=self._timeout, - ) - - def heartbeat(self, token: str) -> ApiTokenStatus: - with self._httpx_client as client: - response = client.get( - "/tokens/current/heartbeat", - headers={"Authorization": f"Bearer {token}"}, - ) - _validate_response(response) - try: - resp_json = response.json() - data = resp_json["data"] - except (ValueError, KeyError) as exc: - raise ApiException("Invalid response format") from exc - - return ApiTokenStatus(**data) - - def rotate(self, token: str) -> RotatedApiToken: - with self._httpx_client as client: - response = client.post( - "/tokens/current/rotate", - headers={"Authorization": f"Bearer {token}"}, - ) - _validate_response(response) - try: - resp_json = response.json() - data = resp_json["data"] - except (ValueError, KeyError) as exc: - raise ApiException("Invalid response format") from exc - - return RotatedApiToken(**data) - - -class AsyncApiTokensAPI: - def __init__(self) -> None: - self._base_url = build_base_url() - self._headers = _build_headers() - self._timeout = DEFAULT_TIMEOUT.total_seconds() - - @property - def _httpx_client(self) -> httpx.AsyncClient: - return httpx.AsyncClient( - base_url=self._base_url, - headers=self._headers, - timeout=self._timeout, - ) - - async def heartbeat(self, token: str) -> ApiTokenStatus: - async with self._httpx_client as client: - response = await client.get( - "/tokens/current/heartbeat", - headers={"Authorization": f"Bearer {token}"}, - ) - _validate_response(response) - try: - resp_json = response.json() - data = resp_json["data"] - except (ValueError, KeyError) as exc: - raise ApiException("Invalid response format") from exc - - return ApiTokenStatus(**data) - - async def rotate(self, token: str) -> RotatedApiToken: - async with self._httpx_client as client: - response = await client.post( - "/tokens/current/rotate", - headers={"Authorization": f"Bearer {token}"}, - ) - _validate_response(response) - try: - resp_json = response.json() - data = resp_json["data"] - except (ValueError, KeyError) as exc: - raise ApiException("Invalid response format") from exc - - return RotatedApiToken(**data) diff --git a/rtbhouse_sdk/api_tokens/cli.py b/rtbhouse_sdk/api_tokens/cli.py new file mode 100644 index 0000000..26fba2f --- /dev/null +++ b/rtbhouse_sdk/api_tokens/cli.py @@ -0,0 +1,192 @@ +"""CLI for API token management.""" + +import argparse +import getpass +import os +import sys +from pathlib import Path +from typing import Any + +from rtbhouse_sdk import schema +from rtbhouse_sdk.api_tokens.managers import ApiTokenExpiredException, ApiTokenManager +from rtbhouse_sdk.api_tokens.models import ApiToken +from rtbhouse_sdk.api_tokens.storages.base import ApiTokenStorageException +from rtbhouse_sdk.api_tokens.storages.json_file import DEFAULT_JSON_FILE_PATH, JsonFileApiTokenStorage +from rtbhouse_sdk.client import ApiTokenAuth, Client +from rtbhouse_sdk.exceptions import ApiRequestException + + +def _read_token_from_stdin() -> str: + if sys.stdin.isatty(): + token = getpass.getpass("Paste API token: ").strip() + return token + + token = sys.stdin.read().strip() + + return token + + +def _resolve_token( + *, + token_arg: str | None = None, + env_var: str | None = None, +) -> str: + print(token_arg, env_var) + if token_arg: + return token_arg.strip() + + if env_var: + env_val = os.getenv(env_var) + return env_val.strip() if env_val else "" + + return _read_token_from_stdin() + + +def _get_token(token: str) -> schema.ApiToken: + with Client(auth=ApiTokenAuth(token=token)) as client: + return client.get_current_api_token() + + +def cmd_init_json(args: argparse.Namespace) -> int: + path = Path(args.path).expanduser() + + token = _resolve_token(token_arg=args.token, env_var=args.env_var) + if not token: + print("ERROR: Empty token. Aborting.", file=sys.stderr) + return 1 + + try: + api_token_response = _get_token(token) + except ApiRequestException as e: + print(f"ERROR: Could not verify token via API or token is invalid. Original error: {e}.", file=sys.stderr) + return 1 + + storage = JsonFileApiTokenStorage(path) + api_token = ApiToken( + token=token, + expires_at=api_token_response.expires_at, + ) + storage.save(api_token) + + print(f"OK: Token successfully initialized in {path}", file=sys.stdout) + return 0 + + +def cmd_keep_alive_json(args: argparse.Namespace) -> int: + path = Path(args.path).expanduser() + storage = JsonFileApiTokenStorage(path) + manager = ApiTokenManager(storage=storage) + + try: + manager.keep_alive(args.auto_rotate) + except (ApiTokenStorageException, ApiRequestException, ApiTokenExpiredException) as e: + print(f"ERROR: Keep-alive failed. Original error: {e}", file=sys.stderr) + return 1 + + print("OK: Token keep-alive successful", file=sys.stdout) + return 0 + + +def cmd_keep_alive(args: argparse.Namespace) -> int: + token = _resolve_token(token_arg=args.token, env_var=args.env_var) + if not token: + print("ERROR: Empty token. Aborting.", file=sys.stderr) + return 1 + + try: + api_token_response = _get_token(token) + except ApiRequestException as e: + print(f"ERROR: Could not verify token via API or token is invalid. Original error: {e}.", file=sys.stderr) + return 1 + + if api_token_response.can_rotate: + print("WARNING: token can be rotated. Consider rotating it before it expires.", file=sys.stderr) + + print("OK: Token keep-alive successful", file=sys.stdout) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="rtbhouse_sdk", + description="RTB House Python SDK CLI", + ) + sub = parser.add_subparsers(dest="command", required=True) + + api_token = sub.add_parser( + "api-token", + help="API token utilities", + ) + api_token_sub = api_token.add_subparsers( + dest="api_token_cmd", + required=True, + ) + build_cmd_init_json(api_token_sub) + build_cmd_keep_alive_json(api_token_sub) + build_cmd_keep_alive(api_token_sub) + + return parser + + +def build_cmd_init_json(parser: Any) -> None: + init_json = parser.add_parser( + "init-json", + help="Initialize API token JSON file storage", + ) + init_json.add_argument( + "--token", + default=None, + help="Token value (discouraged). Prefer stdin/env/pipe.", + ) + init_json.add_argument( + "--path", + default=DEFAULT_JSON_FILE_PATH, + help=f"Path to token JSON file (default: {DEFAULT_JSON_FILE_PATH})", + ) + init_json.add_argument( + "--env-var", + help="Environment variable to read token from.", + ) + init_json.set_defaults(func=cmd_init_json) + + +def build_cmd_keep_alive_json(parser: Any) -> None: + keep_alive_json = parser.add_parser( + "keep-alive-json", + help="Keep alive API token stored in JSON file storage by bumping its usage. Allows automatic rotation.", + ) + keep_alive_json.add_argument( + "--path", + default=DEFAULT_JSON_FILE_PATH, + help=f"Path to token JSON file (default: {DEFAULT_JSON_FILE_PATH})", + ) + keep_alive_json.add_argument( + "--auto-rotate", + action="store_true", + help="Enable automatic rotation if token is in rotation window", + ) + keep_alive_json.set_defaults(func=cmd_keep_alive_json) + + +def build_cmd_keep_alive(parser: Any) -> None: + keep_alive = parser.add_parser( + "keep-alive", + help="Keep alive API token by bumping its usage.", + ) + keep_alive.add_argument( + "--token", + default=None, + help="Token value (discouraged). Prefer stdin/env/pipe.", + ) + keep_alive.add_argument( + "--env-var", + default=None, + help="Environment variable to read token from.", + ) + keep_alive.set_defaults(func=cmd_keep_alive) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) diff --git a/rtbhouse_sdk/api_tokens/managers.py b/rtbhouse_sdk/api_tokens/managers.py index ac615d8..75894cb 100644 --- a/rtbhouse_sdk/api_tokens/managers.py +++ b/rtbhouse_sdk/api_tokens/managers.py @@ -1,82 +1,50 @@ """Contains classes for managing API tokens""" import asyncio -import os import threading import warnings +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager from datetime import datetime, timedelta from rtbhouse_sdk._utils import utcnow -from rtbhouse_sdk.api_tokens.api import ApiTokensAPI, AsyncApiTokensAPI from rtbhouse_sdk.api_tokens.models import ApiToken -from rtbhouse_sdk.api_tokens.storages import ApiTokenStorage, ApiTokenStorageException, AsyncApiTokenStorage -from rtbhouse_sdk.client import ApiTokenProvider, AsyncApiTokenProvider +from rtbhouse_sdk.api_tokens.storages.base import ApiTokenStorage, AsyncApiTokenStorage +from rtbhouse_sdk.client import ApiTokenAuth, AsyncClient, AsyncDynamicApiTokenAuth, Client, DynamicApiTokenAuth +from rtbhouse_sdk.exceptions import ApiRequestException ROTATION_WINDOW = timedelta(days=4) -DEFAULT_TOKEN_CONFIGURE_ENV_VAR = "RTBH_API_TOKEN" -EXPIRED_MSG = "API token expired. Please manually create a new one and configure it by calling the configure() method." +EXPIRED_MSG = "API token expired. Please manually create a new one and configure it in storage." -class TokenExpiredException(Exception): +class ApiTokenExpiredException(Exception): """Exception raised when the API token has expired.""" -class ApiTokenManager(ApiTokenProvider): +class ApiTokenManager(DynamicApiTokenAuth): """ - Manages the lifecycle of an API token, including configuration, rotation and expiration handling. - - Initial token configuration example: - ``` - manager = ApiTokenManager(storage) - manager.configure(token="your_initial_token") - ``` - - You can also configure it from environment variable: - ``` - manager.configure_from_env(env_var="RTBH_API_TOKEN") - ``` + Manages the lifecycle of an API token, including token retrieval, rotation and expiration handling. """ + _storage: ApiTokenStorage + _lock: threading.Lock + _expiration_margin: timedelta + def __init__(self, storage: ApiTokenStorage) -> None: + super().__init__() self._storage = storage - self._api = ApiTokensAPI() self._lock = threading.Lock() self._expiration_margin = timedelta(minutes=1) - def configure(self, token: str) -> None: - status = self._api.heartbeat(token) - - if status.is_expired: - raise TokenExpiredException(EXPIRED_MSG) - - api_token = ApiToken( - token=token, - expires_at=status.expires_at, - ) - self._storage.save(api_token) - - def configure_from_env( - self, - env_var: str = DEFAULT_TOKEN_CONFIGURE_ENV_VAR, - overwrite: bool = False, - ) -> None: - token = os.getenv(env_var) - if token is None: - raise ValueError(f"Environment variable '{env_var}' is not set") - - if not overwrite and self.is_configured(): - return - - self.configure(token) - - def is_configured(self) -> bool: + @contextmanager + def with_client(self, token: str) -> Iterator[Client]: + client = Client(auth=ApiTokenAuth(token=token)) try: - self._storage.load() - return True - except ApiTokenStorageException: - return False + yield client + finally: + client.close() def get_token(self) -> str: api_token = self._storage.load() @@ -98,98 +66,67 @@ def get_token(self) -> str: if not _in_rotation_window(api_token, now): return api_token.token - status = self._api.heartbeat(api_token.token) + with self.with_client(api_token.token) as client: + try: + rotated = client.rotate_current_api_token() - if status.is_expired: - raise TokenExpiredException(EXPIRED_MSG) - - if not status.can_rotate: - if _in_rotation_window(api_token, now): + api_token = ApiToken( + token=rotated.token, + expires_at=rotated.expires_at, + ) + self._storage.save(api_token) + except ApiRequestException as e: warnings.warn( - "Couldn't rotate API token and it may expire soon. " - "Please check whether it has already been rotated." + f"Attempted to rotate API token but failed. " + "Please check whether the token has already been rotated. " + f"Original error: {e}" ) - return api_token.token - - rotated = self._api.rotate(api_token.token) - new_api_token = ApiToken( - token=rotated.token, - expires_at=rotated.expires_at, - ) - self._storage.save(new_api_token) - - return new_api_token.token + return api_token.token - def keep_alive(self) -> None: + def keep_alive(self, auto_rotate: bool = False) -> None: with self._lock: api_token = self._storage.load() now = utcnow() _raise_if_expired(api_token, now, self._expiration_margin) - status = self._api.heartbeat(api_token.token) + with self.with_client(api_token.token) as client: + # bump used at to now to keep the token alive + client.get_current_api_token() - if status.is_expired: - raise TokenExpiredException(EXPIRED_MSG) + if not auto_rotate: + return - if not status.can_rotate: - if _in_rotation_window(api_token, now): - warnings.warn( - "Couldn't rotate API token and it may expire soon. " - "Please check whether it has already been rotated." - ) - return + rotated = client.rotate_current_api_token() - rotated = self._api.rotate(api_token.token) - new_api_token = ApiToken( + api_token = ApiToken( token=rotated.token, expires_at=rotated.expires_at, ) - self._storage.save(new_api_token) + self._storage.save(api_token) -class AsyncApiTokenManager(AsyncApiTokenProvider): +class AsyncApiTokenManager(AsyncDynamicApiTokenAuth): """Asynchronous version of ApiTokenManager.""" + _storage: AsyncApiTokenStorage + _lock: asyncio.Lock + _expiration_margin: timedelta + def __init__(self, storage: AsyncApiTokenStorage) -> None: + super().__init__() self._storage = storage - self._api = AsyncApiTokensAPI() self._lock = asyncio.Lock() self._expiration_margin = timedelta(minutes=1) - async def configure(self, token: str) -> None: - status = await self._api.heartbeat(token) - - if status.is_expired: - raise TokenExpiredException(EXPIRED_MSG) - - api_token = ApiToken( - token=token, - expires_at=status.expires_at, - ) - await self._storage.save(api_token) - - async def configure_from_env( - self, - env_var: str = DEFAULT_TOKEN_CONFIGURE_ENV_VAR, - overwrite: bool = False, - ) -> None: - token = os.getenv(env_var) - if token is None: - raise ValueError(f"Environment variable '{env_var}' is not set") - - if not overwrite and await self.is_configured(): - return - - await self.configure(token) - - async def is_configured(self) -> bool: + @asynccontextmanager + async def with_client(self, token: str) -> AsyncIterator[AsyncClient]: + client = AsyncClient(auth=ApiTokenAuth(token=token)) try: - await self._storage.load() - return True - except ApiTokenStorageException: - return False + yield client + finally: + await client.close() async def get_token(self) -> str: api_token = await self._storage.load() @@ -211,60 +148,50 @@ async def get_token(self) -> str: if not _in_rotation_window(api_token, now): return api_token.token - status = await self._api.heartbeat(api_token.token) + async with self.with_client(api_token.token) as client: + try: + rotated = await client.rotate_current_api_token() - if status.is_expired: - raise TokenExpiredException(EXPIRED_MSG) - - if not status.can_rotate: - if _in_rotation_window(api_token, now): + api_token = ApiToken( + token=rotated.token, + expires_at=rotated.expires_at, + ) + await self._storage.save(api_token) + except ApiRequestException as e: warnings.warn( - "Couldn't rotate API token and it may expire soon. " - "Please check whether it has already been rotated." + f"Attempted to rotate API token but failed. " + "Please check whether the token has already been rotated. " + f"Original error: {e}" ) - return api_token.token - - rotated = await self._api.rotate(api_token.token) - new_api_token = ApiToken( - token=rotated.token, - expires_at=rotated.expires_at, - ) - await self._storage.save(new_api_token) - - return new_api_token.token + return api_token.token - async def keep_alive(self) -> None: + async def keep_alive(self, auto_rotate: bool) -> None: async with self._lock: api_token = await self._storage.load() now = utcnow() _raise_if_expired(api_token, now, self._expiration_margin) - status = await self._api.heartbeat(api_token.token) + async with self.with_client(api_token.token) as client: + # bump used at to now to keep the token alive + await client.get_current_api_token() - if status.is_expired: - raise TokenExpiredException(EXPIRED_MSG) + if not auto_rotate: + return - if not status.can_rotate: - if _in_rotation_window(api_token, now): - warnings.warn( - "Couldn't rotate API token and it may expire soon. " - "Please check whether it has already been rotated." - ) - return + rotated = await client.rotate_current_api_token() - rotated = await self._api.rotate(api_token.token) - new_api_token = ApiToken( + api_token = ApiToken( token=rotated.token, expires_at=rotated.expires_at, ) - await self._storage.save(new_api_token) + await self._storage.save(api_token) def _raise_if_expired(api_token: ApiToken, now: datetime, expiration_margin: timedelta) -> None: if now >= api_token.expires_at - expiration_margin: - raise TokenExpiredException(EXPIRED_MSG) + raise ApiTokenExpiredException(EXPIRED_MSG) def _in_rotation_window(api_token: ApiToken, now: datetime) -> bool: diff --git a/rtbhouse_sdk/api_tokens/storages/__init__.py b/rtbhouse_sdk/api_tokens/storages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rtbhouse_sdk/api_tokens/storages/base.py b/rtbhouse_sdk/api_tokens/storages/base.py new file mode 100644 index 0000000..f614b53 --- /dev/null +++ b/rtbhouse_sdk/api_tokens/storages/base.py @@ -0,0 +1,33 @@ +"""Base classes for API token storage implementations.""" + +from abc import ABC, abstractmethod + +from rtbhouse_sdk.api_tokens.models import ApiToken + + +class ApiTokenStorageException(Exception): + """Exception raised for errors in the API token storage operations.""" + + +class ApiTokenStorage(ABC): + """Abstract base class for API token storage implementations.""" + + @abstractmethod + def load(self) -> ApiToken: + pass + + @abstractmethod + def save(self, api_token: ApiToken) -> None: + pass + + +class AsyncApiTokenStorage(ABC): + """Abstract base class for asynchronous API token storage implementations.""" + + @abstractmethod + async def load(self) -> ApiToken: + pass + + @abstractmethod + async def save(self, api_token: ApiToken) -> None: + pass diff --git a/rtbhouse_sdk/api_tokens/storages/in_memory.py b/rtbhouse_sdk/api_tokens/storages/in_memory.py new file mode 100644 index 0000000..4e6f0a4 --- /dev/null +++ b/rtbhouse_sdk/api_tokens/storages/in_memory.py @@ -0,0 +1,41 @@ +"""In-memory storage for API tokens.""" + +from rtbhouse_sdk.api_tokens.models import ApiToken + +from .base import ApiTokenStorage, ApiTokenStorageException, AsyncApiTokenStorage + + +class InMemoryApiTokenStorage(ApiTokenStorage): + """In-memory storage for API tokens.""" + + _api_token: ApiToken + + def __init__(self, api_token: ApiToken) -> None: + super().__init__() + self._api_token = api_token + + def load(self) -> ApiToken: + if self._api_token is None: + raise ApiTokenStorageException("No API token stored in memory") + return self._api_token + + def save(self, api_token: ApiToken) -> None: + self._api_token = api_token + + +class AsyncInMemoryApiTokenStorage(AsyncApiTokenStorage): + """Asynchronous in-memory storage for API tokens.""" + + _api_token: ApiToken + + def __init__(self, api_token: ApiToken) -> None: + super().__init__() + self._api_token = api_token + + async def load(self) -> ApiToken: + if self._api_token is None: + raise ApiTokenStorageException("No API token stored in memory") + return self._api_token + + async def save(self, api_token: ApiToken) -> None: + self._api_token = api_token diff --git a/rtbhouse_sdk/api_tokens/storages.py b/rtbhouse_sdk/api_tokens/storages/json_file.py similarity index 50% rename from rtbhouse_sdk/api_tokens/storages.py rename to rtbhouse_sdk/api_tokens/storages/json_file.py index c27e7d7..30c3144 100644 --- a/rtbhouse_sdk/api_tokens/storages.py +++ b/rtbhouse_sdk/api_tokens/storages/json_file.py @@ -1,62 +1,32 @@ -"""Contains classes for API token storage implementations.""" +"""JSON file storage for API tokens.""" import asyncio import os import tempfile -from abc import ABC, abstractmethod from pathlib import Path -from rtbhouse_sdk.api_tokens.models import ApiToken - -DEFAULT_JSON_FILE_PATH = "~/.rtbhouse/api_token.json" - - -class ApiTokenStorageException(Exception): - """Exception raised for errors in the API token storage operations.""" - - -class ApiTokenStorage(ABC): - """Abstract base class for API token storage implementations.""" - - @abstractmethod - def load(self) -> ApiToken: - pass - - @abstractmethod - def save(self, api_token: ApiToken) -> None: - pass +from cachetools import TTLCache, cachedmethod +from cachetools.keys import hashkey - @abstractmethod - def delete(self) -> None: - pass - - -class InMemoryApiTokenStorage(ApiTokenStorage): - """In-memory storage for API tokens.""" - - def __init__(self, api_token: ApiToken | None = None) -> None: - super().__init__() - self._api_token = api_token - - def load(self) -> ApiToken: - if self._api_token is None: - raise ApiTokenStorageException("No API token stored in memory") - return self._api_token +from rtbhouse_sdk.api_tokens.models import ApiToken - def save(self, api_token: ApiToken) -> None: - self._api_token = api_token +from .base import ApiTokenStorage, ApiTokenStorageException, AsyncApiTokenStorage - def delete(self) -> None: - self._api_token = None +DEFAULT_JSON_FILE_PATH = "~/.rtbhouse/api_token.json" class JsonFileApiTokenStorage(ApiTokenStorage): """JSON file storage for API tokens.""" + _path: Path + _load_cache: TTLCache[str, ApiToken] + def __init__(self, path: str | Path = DEFAULT_JSON_FILE_PATH) -> None: super().__init__() self._path = Path(path).expanduser() + self._load_cache = TTLCache(maxsize=1, ttl=300) + @cachedmethod(lambda self: self._load_cache, key=lambda self: hashkey()) def load(self) -> ApiToken: try: text = self._path.read_text(encoding="utf-8") @@ -71,15 +41,15 @@ def load(self) -> ApiToken: raise ApiTokenStorageException("Invalid API token JSON file format") from e def save(self, api_token: ApiToken) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.parent.mkdir( + parents=True, + exist_ok=True, + ) + text = api_token.model_dump_json() self._atomic_write_text(text) - def delete(self) -> None: - try: - self._path.unlink() - except FileNotFoundError: - pass + self._load_cache.clear() def _atomic_write_text(self, text: str) -> None: descriptor, temp_path = tempfile.mkstemp( @@ -112,45 +82,13 @@ def _atomic_write_text(self, text: str) -> None: pass -class AsyncApiTokenStorage(ABC): - """Abstract base class for asynchronous API token storage implementations.""" - - @abstractmethod - async def load(self) -> ApiToken: - pass - - @abstractmethod - async def save(self, api_token: ApiToken) -> None: - pass - - @abstractmethod - async def delete(self) -> None: - pass - - -class AsyncInMemoryApiTokenStorage(AsyncApiTokenStorage): - """Asynchronous in-memory storage for API tokens.""" - - def __init__(self, api_token: ApiToken | None = None) -> None: - super().__init__() - self._api_token = api_token - - async def load(self) -> ApiToken: - if self._api_token is None: - raise ApiTokenStorageException("No API token stored in memory") - return self._api_token - - async def save(self, api_token: ApiToken) -> None: - self._api_token = api_token - - async def delete(self) -> None: - self._api_token = None - - class AsyncJsonFileApiTokenStorage(AsyncApiTokenStorage): """Asynchronous JSON file storage for API tokens.""" + _sync: JsonFileApiTokenStorage + def __init__(self, path: str | Path = DEFAULT_JSON_FILE_PATH) -> None: + super().__init__() self._sync = JsonFileApiTokenStorage(path) async def load(self) -> ApiToken: @@ -158,6 +96,3 @@ async def load(self) -> ApiToken: async def save(self, api_token: ApiToken) -> None: return await asyncio.to_thread(self._sync.save, api_token) - - async def delete(self) -> None: - return await asyncio.to_thread(self._sync.delete) diff --git a/rtbhouse_sdk/client.py b/rtbhouse_sdk/client.py index 4b58dac..ee7a5dc 100644 --- a/rtbhouse_sdk/client.py +++ b/rtbhouse_sdk/client.py @@ -34,9 +34,10 @@ class ApiTokenAuth: token: str -@dataclasses.dataclass -class DynamicApiTokenAuth: - manager: "ApiTokenProvider" +class DynamicApiTokenAuth(ABC): # pylint: disable=too-few-public-methods + @abstractmethod + def get_token(self) -> str: + pass @dataclasses.dataclass @@ -264,10 +265,19 @@ def get_summary_stats( data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/summary-stats", params) return [schema.Stats(**st) for st in data] + def get_current_api_token(self) -> schema.ApiToken: + data = self._get_dict("/tokens/current") + return schema.ApiToken(**data) -@dataclasses.dataclass -class AsyncDynamicApiTokenAuth: - manager: "AsyncApiTokenProvider" + def rotate_current_api_token(self) -> schema.RotatedApiToken: + data = self._post_dict("/tokens/current/rotate") + return schema.RotatedApiToken(**data) + + +class AsyncDynamicApiTokenAuth(ABC): # pylint: disable=too-few-public-methods + @abstractmethod + async def get_token(self) -> str: + pass class AsyncClient: @@ -318,8 +328,12 @@ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: except (ValueError, KeyError) as exc: raise ApiException("Invalid response format") from exc - async def _post(self, path: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> Any: - response = await self._httpx_client.post(path, json=data, params=params) + async def _post(self, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None) -> Any: + response = await self._httpx_client.post( + path, + json=data, + params=params, + ) _validate_response(response) try: resp_json = response.json() @@ -333,6 +347,14 @@ async def _get_dict(self, path: str, params: dict[str, Any] | None = None) -> di raise ValueError("Result is not a dict") return data + async def _post_dict( + self, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + data = await self._post(path, data, params) + if not isinstance(data, dict): + raise ValueError("Result is not a dict") + return data + async def _get_list_of_dicts(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: data = await self._get(path, params) if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): @@ -467,6 +489,14 @@ async def get_summary_stats( data = await self._get_list_of_dicts(f"/advertisers/{adv_hash}/summary-stats", params) return [schema.Stats(**st) for st in data] + async def get_current_api_token(self) -> schema.ApiToken: + data = await self._get_dict("/tokens/current") + return schema.ApiToken(**data) + + async def rotate_current_api_token(self) -> schema.RotatedApiToken: + data = await self._post_dict("/tokens/current/rotate") + return schema.RotatedApiToken(**data) + class _HttpxApiTokenAuth(httpx.Auth): """API token auth backend.""" @@ -479,7 +509,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re yield request -class _HttpxProviderApiTokenAuth(httpx.Auth): +class _HttpxDynamicApiTokenAuth(httpx.Auth): """API token auth backend.""" def __init__(self, token_provider: Callable[[], str]) -> None: @@ -490,8 +520,14 @@ def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, htt request.headers["Authorization"] = f"Bearer {token}" yield request + async def async_auth_flow(self, _: httpx.Request) -> AsyncGenerator[ # type: ignore[override] + httpx.Request, + httpx.Response, + ]: + raise RuntimeError("This auth backend does not support async mode") -class _AsyncHttpxProviderApiTokenAuth(httpx.Auth): + +class _AsyncHttpxDynamicApiTokenAuth(httpx.Auth): """API token auth backend.""" def __init__(self, token_provider: Callable[[], Awaitable[str]]) -> None: @@ -502,6 +538,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. request.headers["Authorization"] = f"Bearer {token}" yield request + def sync_auth_flow(self, _: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + raise RuntimeError("This auth backend does not support sync mode") + class _HttpxBasicTokenAuth(httpx.Auth): """Basic token auth backend.""" @@ -514,18 +553,6 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re yield request -class ApiTokenProvider(ABC): # pylint: disable=too-few-public-methods - @abstractmethod - def get_token(self) -> str: - pass - - -class AsyncApiTokenProvider(ABC): # pylint: disable=too-few-public-methods - @abstractmethod - async def get_token(self) -> str: - pass - - def build_base_url() -> str: return f"{API_BASE_URL}/{API_VERSION}" @@ -542,10 +569,10 @@ def _choose_auth_backend( match auth: case ApiTokenAuth(token=token): return _HttpxApiTokenAuth(token) - case DynamicApiTokenAuth(manager=manager): - return _HttpxProviderApiTokenAuth(manager.get_token) - case AsyncDynamicApiTokenAuth(manager=manager): - return _AsyncHttpxProviderApiTokenAuth(manager.get_token) + case DynamicApiTokenAuth(): + return _HttpxDynamicApiTokenAuth(auth.get_token) + case AsyncDynamicApiTokenAuth(): + return _AsyncHttpxDynamicApiTokenAuth(auth.get_token) case BasicAuth(username=username, password=password): return httpx.BasicAuth(username, password) case BasicTokenAuth(token=token): diff --git a/rtbhouse_sdk/schema.py b/rtbhouse_sdk/schema.py index bb4ad42..997f02d 100644 --- a/rtbhouse_sdk/schema.py +++ b/rtbhouse_sdk/schema.py @@ -272,3 +272,13 @@ class Stats(CamelizedBaseModel): cpvisit: float | None = None user_frequency: float | None = None user_reach: float | None = None + + +class RotatedApiToken(CamelizedBaseModel): + token: str + expires_at: datetime + + +class ApiToken(CamelizedBaseModel): + expires_at: datetime + can_rotate: bool From b6f2f647098f9695426840d34ae682a9219a6c37 Mon Sep 17 00:00:00 2001 From: Jakub Szymanski Date: Mon, 9 Mar 2026 10:13:57 +0100 Subject: [PATCH 4/5] fix imports --- rtbhouse_sdk/api_tokens/cli.py | 16 ++++++++-------- rtbhouse_sdk/api_tokens/managers.py | 10 +++++----- rtbhouse_sdk/api_tokens/storages/base.py | 2 +- rtbhouse_sdk/api_tokens/storages/in_memory.py | 3 +-- rtbhouse_sdk/api_tokens/storages/json_file.py | 3 +-- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/rtbhouse_sdk/api_tokens/cli.py b/rtbhouse_sdk/api_tokens/cli.py index 26fba2f..fe3efcb 100644 --- a/rtbhouse_sdk/api_tokens/cli.py +++ b/rtbhouse_sdk/api_tokens/cli.py @@ -7,13 +7,13 @@ from pathlib import Path from typing import Any -from rtbhouse_sdk import schema -from rtbhouse_sdk.api_tokens.managers import ApiTokenExpiredException, ApiTokenManager -from rtbhouse_sdk.api_tokens.models import ApiToken -from rtbhouse_sdk.api_tokens.storages.base import ApiTokenStorageException -from rtbhouse_sdk.api_tokens.storages.json_file import DEFAULT_JSON_FILE_PATH, JsonFileApiTokenStorage -from rtbhouse_sdk.client import ApiTokenAuth, Client -from rtbhouse_sdk.exceptions import ApiRequestException +from ..client import ApiTokenAuth, Client +from ..exceptions import ApiRequestException +from ..schema import ApiToken as ApiTokenResponse +from .managers import ApiTokenExpiredException, ApiTokenManager +from .models import ApiToken +from .storages.base import ApiTokenStorageException +from .storages.json_file import DEFAULT_JSON_FILE_PATH, JsonFileApiTokenStorage def _read_token_from_stdin() -> str: @@ -42,7 +42,7 @@ def _resolve_token( return _read_token_from_stdin() -def _get_token(token: str) -> schema.ApiToken: +def _get_token(token: str) -> ApiTokenResponse: with Client(auth=ApiTokenAuth(token=token)) as client: return client.get_current_api_token() diff --git a/rtbhouse_sdk/api_tokens/managers.py b/rtbhouse_sdk/api_tokens/managers.py index 75894cb..be38975 100644 --- a/rtbhouse_sdk/api_tokens/managers.py +++ b/rtbhouse_sdk/api_tokens/managers.py @@ -7,11 +7,11 @@ from contextlib import asynccontextmanager, contextmanager from datetime import datetime, timedelta -from rtbhouse_sdk._utils import utcnow -from rtbhouse_sdk.api_tokens.models import ApiToken -from rtbhouse_sdk.api_tokens.storages.base import ApiTokenStorage, AsyncApiTokenStorage -from rtbhouse_sdk.client import ApiTokenAuth, AsyncClient, AsyncDynamicApiTokenAuth, Client, DynamicApiTokenAuth -from rtbhouse_sdk.exceptions import ApiRequestException +from .._utils import utcnow +from ..client import ApiTokenAuth, AsyncClient, AsyncDynamicApiTokenAuth, Client, DynamicApiTokenAuth +from ..exceptions import ApiRequestException +from .models import ApiToken +from .storages.base import ApiTokenStorage, AsyncApiTokenStorage ROTATION_WINDOW = timedelta(days=4) diff --git a/rtbhouse_sdk/api_tokens/storages/base.py b/rtbhouse_sdk/api_tokens/storages/base.py index f614b53..39071c7 100644 --- a/rtbhouse_sdk/api_tokens/storages/base.py +++ b/rtbhouse_sdk/api_tokens/storages/base.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod -from rtbhouse_sdk.api_tokens.models import ApiToken +from ..models import ApiToken class ApiTokenStorageException(Exception): diff --git a/rtbhouse_sdk/api_tokens/storages/in_memory.py b/rtbhouse_sdk/api_tokens/storages/in_memory.py index 4e6f0a4..9f14bac 100644 --- a/rtbhouse_sdk/api_tokens/storages/in_memory.py +++ b/rtbhouse_sdk/api_tokens/storages/in_memory.py @@ -1,7 +1,6 @@ """In-memory storage for API tokens.""" -from rtbhouse_sdk.api_tokens.models import ApiToken - +from ..models import ApiToken from .base import ApiTokenStorage, ApiTokenStorageException, AsyncApiTokenStorage diff --git a/rtbhouse_sdk/api_tokens/storages/json_file.py b/rtbhouse_sdk/api_tokens/storages/json_file.py index 30c3144..c17687d 100644 --- a/rtbhouse_sdk/api_tokens/storages/json_file.py +++ b/rtbhouse_sdk/api_tokens/storages/json_file.py @@ -8,8 +8,7 @@ from cachetools import TTLCache, cachedmethod from cachetools.keys import hashkey -from rtbhouse_sdk.api_tokens.models import ApiToken - +from ..models import ApiToken from .base import ApiTokenStorage, ApiTokenStorageException, AsyncApiTokenStorage DEFAULT_JSON_FILE_PATH = "~/.rtbhouse/api_token.json" From 83c6afc81edec32ae0829660ea1070b684bbbb65 Mon Sep 17 00:00:00 2001 From: Jakub Szymanski Date: Mon, 9 Mar 2026 10:26:32 +0100 Subject: [PATCH 5/5] add rotation window condition to keep-alive --- rtbhouse_sdk/api_tokens/managers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rtbhouse_sdk/api_tokens/managers.py b/rtbhouse_sdk/api_tokens/managers.py index be38975..f48c264 100644 --- a/rtbhouse_sdk/api_tokens/managers.py +++ b/rtbhouse_sdk/api_tokens/managers.py @@ -98,6 +98,9 @@ def keep_alive(self, auto_rotate: bool = False) -> None: if not auto_rotate: return + if not _in_rotation_window(api_token, now): + return + rotated = client.rotate_current_api_token() api_token = ApiToken( @@ -180,6 +183,9 @@ async def keep_alive(self, auto_rotate: bool) -> None: if not auto_rotate: return + if not _in_rotation_window(api_token, now): + return + rotated = await client.rotate_current_api_token() api_token = ApiToken(